From e922274b64bbf72c556dbae71134963e986e5dcf Mon Sep 17 00:00:00 2001 From: "Ilya.Kazakevich" Date: Tue, 23 Dec 2025 23:10:57 +0100 Subject: [PATCH] [python] PY-79486, PY-86454: WIP: Automatic SDK configuration, see `ModuleConfigurationMode` Two modes are described in `ModuleConfigurationMode` GitOrigin-RevId: bf6508571fd2f31ee87dd4b52c293155583c6eea --- python/python-pyproject/BUILD.bazel | 2 + .../intellij.python.pyproject.iml | 1 + .../autoImportBridge/FsInfoStorage.kt | 26 --- .../PyExternalSystemProjectAware.kt | 40 ++++- .../PyProjectAutoImportService.kt | 10 +- .../platformBridge/PyProjectSyncActivity.kt | 1 + .../platformBridge/PyProjectTomlSyncAction.kt | 6 +- .../internal/pyProjectToml/FSWalkInfo.kt | 7 - .../pyProjectToml/FSWalkInfoWithToml.kt | 12 ++ .../internal/pyProjectToml/tomFileTools.kt | 34 ++-- .../workspaceBridge/workspaceTools.kt | 38 ++++- .../PyExternalSystemProjectAwareTest.kt | 70 ++++++++ .../intellij.python.pyproject._test.xml | 1 + ...ntellij.python.sdkConfigurator.backend.xml | 4 +- .../src/impl/ModuleConfigurationMode.kt | 20 +++ .../src/impl/ModulesSdkConfigurator.kt | 156 ++++++++++++------ .../backend/src/impl/backendLib.kt | 31 ++-- .../impl/platformBridge/ConfigureSDKAction.kt | 6 +- ...ModelRebuiltListenerImplToConfigureSdk.kt} | 7 +- .../impl/rpcBridge/SdkConfiguratorApiImpl.kt | 5 +- .../python/sdk/configuration/CreateSdkInfo.kt | 8 +- 21 files changed, 347 insertions(+), 138 deletions(-) delete mode 100644 python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/FsInfoStorage.kt delete mode 100644 python/python-pyproject/src/com/intellij/python/pyproject/model/internal/pyProjectToml/FSWalkInfo.kt create mode 100644 python/python-pyproject/src/com/intellij/python/pyproject/model/internal/pyProjectToml/FSWalkInfoWithToml.kt create mode 100644 python/python-pyproject/test/com/intellij/python/junit5Tests/unit/pyproject/PyExternalSystemProjectAwareTest.kt create mode 100644 python/python-sdk-configurator/backend/src/impl/ModuleConfigurationMode.kt rename python/python-sdk-configurator/backend/src/impl/platformBridge/{ModelRebuiltListenerImpl.kt => ModelRebuiltListenerImplToConfigureSdk.kt} (70%) diff --git a/python/python-pyproject/BUILD.bazel b/python/python-pyproject/BUILD.bazel index f9012b208f4c..5afe25bdd591 100644 --- a/python/python-pyproject/BUILD.bazel +++ b/python/python-pyproject/BUILD.bazel @@ -85,6 +85,8 @@ jvm_library( "//platform/testFramework/junit5/projectStructure", "//platform/testFramework/junit5/projectStructure:projectStructure_test_lib", "//python/junit5Tests-framework:community-junit5Tests-framework_test_lib", + "//platform/testFramework", + "//platform/testFramework:testFramework_test_lib", ], exports = ["@lib//:tuweni-toml"] ) diff --git a/python/python-pyproject/intellij.python.pyproject.iml b/python/python-pyproject/intellij.python.pyproject.iml index ba3b26270448..9bd45a1bb3e7 100644 --- a/python/python-pyproject/intellij.python.pyproject.iml +++ b/python/python-pyproject/intellij.python.pyproject.iml @@ -38,5 +38,6 @@ + \ No newline at end of file diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/FsInfoStorage.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/FsInfoStorage.kt deleted file mode 100644 index 597709bca805..000000000000 --- a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/FsInfoStorage.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.intellij.python.pyproject.model.internal.autoImportBridge - -import com.intellij.python.pyproject.model.internal.pyProjectToml.FSWalkInfo -import com.intellij.python.pyproject.model.internal.pyProjectToml.walkFileSystem -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import java.nio.file.Path - -internal class FsInfoStorage(private val projectRootDir: Path) { - private val updateMut = Mutex() - - private var cache: FSWalkInfo? = null - - - suspend fun getFsInfo(forceRefresh: Boolean): FSWalkInfo { - updateMut.withLock { - val cache = cache - if (cache != null && !forceRefresh) { - return cache - } - val fsInfo = walkFileSystem(projectRootDir) - this.cache = fsInfo - return fsInfo - } - } -} \ No newline at end of file diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/PyExternalSystemProjectAware.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/PyExternalSystemProjectAware.kt index c27953b8c739..92632119534c 100644 --- a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/PyExternalSystemProjectAware.kt +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/PyExternalSystemProjectAware.kt @@ -1,19 +1,25 @@ package com.intellij.python.pyproject.model.internal.autoImportBridge import com.intellij.openapi.Disposable +import com.intellij.openapi.application.writeAction +import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.externalSystem.autoimport.* import com.intellij.openapi.externalSystem.model.ProjectSystemId import com.intellij.openapi.progress.runBlockingMaybeCancellable import com.intellij.openapi.project.Project -import com.intellij.openapi.project.guessProjectDir import com.intellij.platform.backend.observation.launchTracked +import com.intellij.project.stateStore +import com.intellij.psi.PsiDocumentManager import com.intellij.python.pyproject.model.api.ModelRebuiltListener import com.intellij.python.pyproject.model.internal.PyProjectTomlBundle +import com.intellij.python.pyproject.model.internal.pyProjectToml.walkFileSystemNoTomlContent +import com.intellij.python.pyproject.model.internal.pyProjectToml.walkFileSystemWithTomlContent import com.intellij.python.pyproject.model.internal.workspaceBridge.rebuildProjectModel import com.intellij.util.concurrency.annotations.RequiresBackgroundThread import com.intellij.util.messages.Topic import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.nio.file.Path @@ -21,17 +27,17 @@ import kotlin.io.path.pathString internal class PyExternalSystemProjectAware private constructor( private val project: Project, - projectRootDir: Path, + private val projectRootDir: Path, ) : ExternalSystemProjectAware { override val projectId: ExternalSystemProjectId = ExternalSystemProjectId(SYSTEM_ID, projectRootDir.pathString) - private val fsInfo = FsInfoStorage(projectRootDir) @get:RequiresBackgroundThread override val settingsFiles: Set get() = runBlockingMaybeCancellable { - val fsInfo = fsInfo.getFsInfo(forceRefresh = true) - return@runBlockingMaybeCancellable fsInfo.tomlFiles.keys.map { it.pathString }.toSet() + // We do not need file content: only names here. + val fsInfo = walkFileSystemNoTomlContent(projectRootDir) + return@runBlockingMaybeCancellable fsInfo.rawTomlFiles.map { it.pathString }.toSet() } override fun subscribe(listener: ExternalSystemProjectListener, parentDisposable: Disposable) { @@ -39,10 +45,18 @@ internal class PyExternalSystemProjectAware private constructor( } override fun reloadProject(context: ExternalSystemProjectReloadContext) { - project.service().scope.launchTracked { + reloadProjectImpl() + } + + internal fun reloadProjectImpl() { + project.service().scope.launchTracked { + writeAction { + // We might get stale files otherwise + PsiDocumentManager.getInstance(project).commitAllDocuments() + } project.messageBus.syncAndPreloadPublisher(PROJECT_AWARE_TOPIC).apply { try { - val files = fsInfo.getFsInfo(forceRefresh = context.hasUndefinedModifications) + val files = walkFileSystemWithTomlContent(projectRootDir) this.onProjectReloadStart() rebuildProjectModel(project, files) this.onProjectReloadFinish(ExternalSystemRefreshStatus.SUCCESS) @@ -65,9 +79,14 @@ internal class PyExternalSystemProjectAware private constructor( companion object { + /** + * [project] can't be default, be sure to check it + */ internal suspend fun create(project: Project): PyExternalSystemProjectAware { + assert(!project.isDefault) { "Default project not supported" } val baseDir = withContext(Dispatchers.IO) { - project.guessProjectDir()?.toNioPath() ?: error("Project $project has no base dir") + // guessPath doesn't work: it returns first module path + project.stateStore.projectBasePath } return PyExternalSystemProjectAware(project, baseDir) } @@ -83,4 +102,7 @@ private val PROJECT_AWARE_TOPIC: Topic = Topic(ExternalSystemProjectListener::class.java, Topic.BroadcastDirection.NONE) @Topic.ProjectLevel -private val MODEL_REBUILD: Topic = Topic(ModelRebuiltListener::class.java, Topic.BroadcastDirection.NONE) \ No newline at end of file +internal val MODEL_REBUILD: Topic = Topic(ModelRebuiltListener::class.java, Topic.BroadcastDirection.NONE) + +@Service(Service.Level.PROJECT) +private class PyExternalSystemProjectAwareService(val scope: CoroutineScope) \ No newline at end of file diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/PyProjectAutoImportService.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/PyProjectAutoImportService.kt index 5337e0936555..79d7dca752d2 100644 --- a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/PyProjectAutoImportService.kt +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/autoImportBridge/PyProjectAutoImportService.kt @@ -5,10 +5,16 @@ import com.intellij.openapi.components.Service import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectId import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectTracker import com.intellij.openapi.project.Project -import kotlinx.coroutines.CoroutineScope +/** + * [project] can't be default, check for it + */ @Service(Service.Level.PROJECT) -internal class PyProjectAutoImportService(private val project: Project, internal val scope: CoroutineScope) : Disposable { +internal class PyProjectAutoImportService(private val project: Project) : Disposable { + init { + assert(!project.isDefault) { "Default project not supported" } + } + @Volatile private lateinit var projectId: ExternalSystemProjectId diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/platformBridge/PyProjectSyncActivity.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/platformBridge/PyProjectSyncActivity.kt index 27c15f54f8f9..778a594eec55 100644 --- a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/platformBridge/PyProjectSyncActivity.kt +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/platformBridge/PyProjectSyncActivity.kt @@ -15,6 +15,7 @@ internal class PyProjectSyncActivity : ProjectActivity { } override suspend fun execute(project: Project) { + if (project.isDefault) return // Service doesn't support default project project.service().start() } } \ No newline at end of file diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/platformBridge/PyProjectTomlSyncAction.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/platformBridge/PyProjectTomlSyncAction.kt index d937a4ab5b6a..99efdd6cdc8d 100644 --- a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/platformBridge/PyProjectTomlSyncAction.kt +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/platformBridge/PyProjectTomlSyncAction.kt @@ -4,7 +4,6 @@ import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service -import com.intellij.openapi.externalSystem.autoimport.ExternalSystemProjectTracker import com.intellij.python.pyproject.model.internal.autoImportBridge.PyProjectAutoImportService import com.intellij.python.pyproject.model.internal.projectModelEnabled @@ -12,14 +11,15 @@ internal class PyProjectTomlSyncAction : AnAction() { override fun actionPerformed(e: AnActionEvent) { val project = e.project ?: return - if (!projectModelEnabled) { + if (!projectModelEnabled || project.isDefault) { // Service doesn't support default project return } project.service().refresh() } override fun update(e: AnActionEvent) { - e.presentation.isEnabledAndVisible = e.project != null && projectModelEnabled + val project = e.project + e.presentation.isEnabledAndVisible = project != null && projectModelEnabled && !project.isDefault } override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/pyProjectToml/FSWalkInfo.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/pyProjectToml/FSWalkInfo.kt deleted file mode 100644 index bcde354a1869..000000000000 --- a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/pyProjectToml/FSWalkInfo.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.intellij.python.pyproject.model.internal.pyProjectToml - -import com.intellij.python.pyproject.PyProjectToml -import com.jetbrains.python.venvReader.Directory -import java.nio.file.Path - -internal data class FSWalkInfo(val tomlFiles: Map, val excludeDir: Set) \ No newline at end of file diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/pyProjectToml/FSWalkInfoWithToml.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/pyProjectToml/FSWalkInfoWithToml.kt new file mode 100644 index 000000000000..4e2e0d6485f7 --- /dev/null +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/pyProjectToml/FSWalkInfoWithToml.kt @@ -0,0 +1,12 @@ +package com.intellij.python.pyproject.model.internal.pyProjectToml + +import com.intellij.python.pyproject.PyProjectToml +import com.jetbrains.python.venvReader.Directory +import java.nio.file.Path + +// Files with toml content +internal data class FSWalkInfoWithToml(val tomlFiles: Map, val excludeDir: Set) + + +// Files only +internal data class FsWalkInfoNoToml(val rawTomlFiles: List, val excludedDirs: List) diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/pyProjectToml/tomFileTools.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/pyProjectToml/tomFileTools.kt index 768ece78f514..d77d170ff494 100644 --- a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/pyProjectToml/tomFileTools.kt +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/pyProjectToml/tomFileTools.kt @@ -1,6 +1,7 @@ package com.intellij.python.pyproject.model.internal.pyProjectToml import com.intellij.openapi.diagnostic.fileLogger +import com.intellij.psi.PsiDocumentManager import com.intellij.python.pyproject.PY_PROJECT_TOML import com.intellij.python.pyproject.PyProjectToml import com.intellij.python.pyproject.model.spi.ProjectName @@ -24,20 +25,33 @@ import kotlin.io.path.visitFileTree // Tools to walk FS and parse pyproject.toml -internal suspend fun walkFileSystem(root: Directory): FSWalkInfo { - val files = ArrayList(10) - val excludeDir = ArrayList(10) +internal suspend fun walkFileSystemWithTomlContent(root: Directory): FSWalkInfoWithToml { + val (rawTomlFiles, excludedDirs) = walkFileSystemNoTomlContent(root) + + // TODO: with a big number of files, use `chunk` to parse them concurrently + val tomlFiles = rawTomlFiles.map { file -> + val toml = readFile(file) ?: return@map null + file to toml + }.filterNotNull().toMap() + return FSWalkInfoWithToml(tomlFiles = tomlFiles, excludedDirs.toSet()) +} + +internal suspend fun walkFileSystemNoTomlContent( + root: Directory, +): FsWalkInfoNoToml { + val excludedDirs = ArrayList(10) + val rawTomlFiles = ArrayList(10) withContext(Dispatchers.IO) { root.visitFileTree { onVisitFile { file, _ -> if (file.name == PY_PROJECT_TOML) { - files.add(file) + rawTomlFiles.add(file) } return@onVisitFile FileVisitResult.CONTINUE } onPostVisitDirectory { directory, _ -> return@onPostVisitDirectory if (directory.name.startsWith(".")) { - excludeDir.add(directory) + excludedDirs.add(directory) FileVisitResult.SKIP_SUBTREE } else { @@ -46,15 +60,11 @@ internal suspend fun walkFileSystem(root: Directory): FSWalkInfo { } } } - // TODO: with a big number of files, use `chunk` to parse them concurrently - val tomlFiles = files.map { file -> - val toml = readFile(file) ?: return@map null - file to toml - }.filterNotNull().toMap() - return FSWalkInfo(tomlFiles = tomlFiles, excludeDir.toSet()) + return FsWalkInfoNoToml(rawTomlFiles = rawTomlFiles, excludedDirs = excludedDirs) } - suspend fun getProjectStructureDefault( + +suspend fun getProjectStructureDefault( entries: Map, rootIndex: Map, ): ProjectStructureInfo = withContext(Dispatchers.Default) { diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/workspaceBridge/workspaceTools.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/workspaceBridge/workspaceTools.kt index b569ac4dd9df..4fe5dbbbc859 100644 --- a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/workspaceBridge/workspaceTools.kt +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/workspaceBridge/workspaceTools.kt @@ -13,7 +13,7 @@ import com.intellij.platform.workspace.storage.url.VirtualFileUrlManager import com.intellij.python.common.tools.ToolId import com.intellij.python.pyproject.PyProjectToml import com.intellij.python.pyproject.model.internal.PyProjectTomlBundle -import com.intellij.python.pyproject.model.internal.pyProjectToml.FSWalkInfo +import com.intellij.python.pyproject.model.internal.pyProjectToml.FSWalkInfoWithToml import com.intellij.python.pyproject.model.internal.pyProjectToml.getProjectStructureDefault import com.intellij.python.pyproject.model.spi.ProjectName import com.intellij.python.pyproject.model.spi.PyProjectTomlProject @@ -21,6 +21,7 @@ import com.intellij.python.pyproject.model.spi.Tool import com.intellij.python.pyproject.model.spi.WorkspaceName import com.intellij.workspaceModel.ide.impl.legacyBridge.facet.FacetModelBridge.Companion.findFacet import com.intellij.workspaceModel.ide.impl.legacyBridge.sdk.SdkBridgeImpl.Companion.findSdkEntity +import com.intellij.workspaceModel.ide.isEqualOrParentOf import com.jetbrains.python.PyNames import com.jetbrains.python.facet.PythonFacetSettings import com.jetbrains.python.venvReader.Directory @@ -36,19 +37,41 @@ import kotlin.io.path.name // Workspace adapter functions -internal suspend fun rebuildProjectModel(project: Project, files: FSWalkInfo) { +internal suspend fun rebuildProjectModel(project: Project, files: FSWalkInfoWithToml) { changeWorkspaceMutex.withLock { - val entries = generatePyProjectTomlEntries(files) + val (entries, excludeDirs) = generatePyProjectTomlEntries(files) val newStorage = createEntityStorage(entries, project.workspaceModel.getVirtualFileUrlManager()) - project.workspaceModel.update(PyProjectTomlBundle.message("action.PyProjectTomlSyncAction.description")) { currentStorage -> // Fake module entity is added by default if nothing was discovered + val workspaceModel = project.workspaceModel + workspaceModel.update(PyProjectTomlBundle.message("action.PyProjectTomlSyncAction.description")) { currentStorage -> // Fake module entity is added by default if nothing was discovered removeFakeModuleEntity(currentStorage, entries.map { it.name.name }.toSet()) + // TODO: Store old module->SDK, so we can restoe it even when modules are destroyed relocateUserDefinedModuleSdk(currentStorage) { currentStorage.replaceBySource({ it is PyProjectTomlEntitySource }, newStorage) + + // Exclude dirs + if (excludeDirs.isEmpty()) return@relocateUserDefinedModuleSdk + val modules = currentStorage.entities(ModuleEntity::class.java).toList() + for (excludedRoot in excludeDirs.map { it.toVirtualFileUrl(workspaceModel.getVirtualFileUrlManager()) }) { + currentStorage.excludeRoot(excludedRoot, modules) + } } } } +} +private fun MutableEntityStorage.excludeRoot(rootToExclude: VirtualFileUrl, modules: List) { + for (moduleEntry in modules) { + for (rootEntity in moduleEntry.contentRoots) { + if (rootEntity.url.isEqualOrParentOf(rootToExclude) && rootToExclude !in rootEntity.excludedUrls.map { it.url }) { + modifyContentRootEntity(rootEntity) { + excludedUrls = excludedUrls + listOf( + ExcludeUrlEntity(rootToExclude, entitySource)) + } + return + } + } + } } /** @@ -89,9 +112,10 @@ internal fun relocateUserDefinedModuleSdk(storage: MutableEntityStorage, transfe } } +// TODO: DOC private suspend fun generatePyProjectTomlEntries( - fsInfo: FSWalkInfo, -): Set = withContext(Dispatchers.Default) { + fsInfo: FSWalkInfoWithToml, +): Pair, Set> = withContext(Dispatchers.Default) { val (files, allExcludeDirs) = fsInfo val entries = ArrayList() val usedNamed = mutableSetOf() @@ -148,7 +172,7 @@ private suspend fun generatePyProjectTomlEntries( entriesByName[member]!!.relationsWithTools.add(PyProjectTomlToolRelation.WorkspaceMember(tool.id, workspace)) } } - return@withContext entries.toSet() + return@withContext Pair(entries.toSet(), allExcludeDirs) } private suspend fun getNameFromEP(projectToml: PyProjectToml): Pair? = withContext(Dispatchers.Default) { diff --git a/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/pyproject/PyExternalSystemProjectAwareTest.kt b/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/pyproject/PyExternalSystemProjectAwareTest.kt new file mode 100644 index 000000000000..c5c071d7d6ea --- /dev/null +++ b/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/pyproject/PyExternalSystemProjectAwareTest.kt @@ -0,0 +1,70 @@ +package com.intellij.python.junit5Tests.unit.pyproject + +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.modules +import com.intellij.python.pyproject.PY_PROJECT_TOML +import com.intellij.python.pyproject.model.api.ModelRebuiltListener +import com.intellij.python.pyproject.model.internal.autoImportBridge.MODEL_REBUILD +import com.intellij.python.pyproject.model.internal.autoImportBridge.PyExternalSystemProjectAware +import com.intellij.testFramework.common.timeoutRunBlocking +import com.intellij.testFramework.junit5.TestApplication +import com.intellij.testFramework.junit5.TestDisposable +import com.intellij.testFramework.junit5.fixture.projectFixture +import com.intellij.testFramework.junit5.fixture.tempPathFixture +import com.intellij.util.io.write +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withContext +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.minutes + + +@Timeout(TIMEOUT_MIN.toLong(), unit = TimeUnit.MINUTES) +@TestApplication +class PyExternalSystemProjectAwareTest { + private val members = arrayOf("foo", "bar").sortedArray() + + private val pathFixture = tempPathFixture() + private val projectFixture = projectFixture(pathFixture) + + @BeforeEach + fun prepareWorkspace(): Unit = timeoutRunBlocking { + val root = pathFixture.get() + for (member in members) { + root.resolve(member).resolve(PY_PROJECT_TOML).write(""" + [project] + name = "$member" + """.trimIndent()) + } + } + + + @Test + fun testBuildProject(@TestDisposable disposable: Disposable): Unit = timeoutRunBlocking(TIMEOUT_MIN.minutes) { + val sut = PyExternalSystemProjectAware.create(projectFixture.get()) + val files = withContext(Dispatchers.IO) { + sut.settingsFiles + } + Assertions.assertEquals(members.size, files.size, "Wrong number of toml files") + + sut.reloadProjectImpl() + + val m = Mutex(locked = true) + projectFixture.get().messageBus.connect(disposable).subscribe(MODEL_REBUILD, ModelRebuiltListener { project -> + try { + val moduleNames = project.modules.map { it.name }.sorted().toTypedArray() + Assertions.assertArrayEquals(members, moduleNames, "Wrong modules created") + } + finally { + m.unlock() + } + }) + m.lock() + } +} + +private const val TIMEOUT_MIN = 2 \ No newline at end of file diff --git a/python/python-pyproject/testResources/intellij.python.pyproject._test.xml b/python/python-pyproject/testResources/intellij.python.pyproject._test.xml index eb250dd9c851..442b0852f4f7 100644 --- a/python/python-pyproject/testResources/intellij.python.pyproject._test.xml +++ b/python/python-pyproject/testResources/intellij.python.pyproject._test.xml @@ -5,5 +5,6 @@ + \ No newline at end of file diff --git a/python/python-sdk-configurator/backend/resources/intellij.python.sdkConfigurator.backend.xml b/python/python-sdk-configurator/backend/resources/intellij.python.sdkConfigurator.backend.xml index 02c3050e282a..8f5a7b317f12 100644 --- a/python/python-sdk-configurator/backend/resources/intellij.python.sdkConfigurator.backend.xml +++ b/python/python-sdk-configurator/backend/resources/intellij.python.sdkConfigurator.backend.xml @@ -1,4 +1,4 @@ - + @@ -11,7 +11,7 @@ messages.PySdkConfiguratorBundle - diff --git a/python/python-sdk-configurator/backend/src/impl/ModuleConfigurationMode.kt b/python/python-sdk-configurator/backend/src/impl/ModuleConfigurationMode.kt new file mode 100644 index 000000000000..a8ca05992aec --- /dev/null +++ b/python/python-sdk-configurator/backend/src/impl/ModuleConfigurationMode.kt @@ -0,0 +1,20 @@ +package com.intellij.python.sdkConfigurator.backend.impl + +/** + * SDK for project modules can be configured in two different modes (see PY-86454) + */ +internal enum class ModuleConfigurationMode { + /** + * Do not bother user with questions, do not touch filesystem. + * For one-module project, configure SDK if files (i.e. venv) already exist. + * For multi-module only set SDK for workspace members if workspace parent SDK is set. + * + * In other words: No more than 1 SDK could be created. No files should be created. + */ + AUTOMATIC, + + /** + * Show user list of modules without SDK, ask them which to configure, and do so. + */ + INTERACTIVE +} diff --git a/python/python-sdk-configurator/backend/src/impl/ModulesSdkConfigurator.kt b/python/python-sdk-configurator/backend/src/impl/ModulesSdkConfigurator.kt index 0dd0dda43140..fc6b55343b59 100644 --- a/python/python-sdk-configurator/backend/src/impl/ModulesSdkConfigurator.kt +++ b/python/python-sdk-configurator/backend/src/impl/ModulesSdkConfigurator.kt @@ -2,6 +2,7 @@ package com.intellij.python.sdkConfigurator.backend.impl import com.intellij.openapi.diagnostic.debug import com.intellij.openapi.diagnostic.fileLogger +import com.intellij.openapi.diagnostic.trace import com.intellij.openapi.module.Module import com.intellij.openapi.project.Project import com.intellij.openapi.project.guessModuleDir @@ -31,9 +32,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext +import org.jetbrains.annotations.ApiStatus /** - * Configures SDK for modules in [project]. + * Configures SDK for modules in [project] in [ModuleConfigurationMode.INTERACTIVE] mode. * * 1. Create instance with [create] * 2. Ask use to choose from [modulesDTO] @@ -83,13 +85,15 @@ internal class ModulesSdkConfigurator private constructor( .toList() } - companion object { + internal companion object { + /** - * Create instance and save in [project] + * Create instance and save in [project], see class doc */ - suspend fun create(project: Project): ModulesSdkConfigurator = ModulesSdkConfigurator(project, getModulesWithoutSDKCreateInfo(project), PathShortener.create(project)).also { - project.putUserData(key, it) - } + suspend fun create(project: Project): ModulesSdkConfigurator = + ModulesSdkConfigurator(project, getModulesWithoutSDKCreateInfo(project), PathShortener.create(project)).also { + project.putUserData(key, it) + } /** * Get instance from project and **clear it** @@ -101,49 +105,22 @@ internal class ModulesSdkConfigurator private constructor( return instance } - private suspend fun getModulesWithoutSDKCreateInfo(project: Project): Map = withBackgroundProgress(project, PySdkConfiguratorBundle.message("intellij.python.sdk.looking")) { - val tools = PyProjectSdkConfigurationExtension.createMap() - val now = System.currentTimeMillis() - val resultDef = project.modules.filter { PythonSdkUtil.findPythonSdk(it) == null }.map { module -> - 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) -> - Pair(module.name, createInfoAndDTO) - } - } - - private val logger = fileLogger() - - private sealed interface ModuleCreateInfo { - data class CreateSdkInfoWrapper(val createSdkInfo: CreateSdkInfo, val toolId: ToolId, val moduleDir: Directory?) : ModuleCreateInfo - data class SameAs(val parentModuleName: ModuleName) : ModuleCreateInfo - } - - - private suspend fun getModuleInfo(module: Module, configuratorsByTool: Map): ModuleCreateInfo? = // 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(r.moduleDir) + private suspend fun getModulesWithoutSDKCreateInfo(project: Project): Map = + withBackgroundProgress(project, PySdkConfiguratorBundle.message("intellij.python.sdk.looking")) { + val tools = PyProjectSdkConfigurationExtension.createMap() + val now = System.currentTimeMillis() + val resultDef = project.modules.filter { PythonSdkUtil.findPythonSdk(it) == null }.map { module -> + async { + val moduleInfo = getModuleInfo(module, tools) ?: return@async null + Pair(module, moduleInfo) } } - is SuggestedSdk.SameAs -> { - ModuleCreateInfo.SameAs(r.parentModule.name) + val result = resultDef.awaitAll().filterNotNull() + logger.debug { "SDKs calculated in ${System.currentTimeMillis() - now}ms" } + result.associate { (module, createInfoAndDTO) -> + Pair(module.name, createInfoAndDTO) } - 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(module.guessModuleDir()?.toNioPath()) } - - - private fun CreateSdkInfoWithTool.asDTO(moduleDir: Directory?): ModuleCreateInfo = ModuleCreateInfo.CreateSdkInfoWrapper(createSdkInfo, toolId, moduleDir) + } /** @@ -204,3 +181,90 @@ internal class ModulesSdkConfigurator private constructor( } } +/** + * See [ModuleConfigurationMode.AUTOMATIC] + */ +@ApiStatus.Internal // Opened for tests only: we can't put tests here because configurators are in communuty.impl +suspend fun configureSdkAutomatically(project: Project): Unit = withContext(Dispatchers.Default) { + val modules = project.modules + + when (modules.size) { + 0 -> return@withContext + 1 -> { // Single-module project, associate first module only + val module = modules.first() + if (PythonSdkUtil.findPythonSdk(module) != null) return@withContext // Already has SDK + when (val moduleInfo = getModuleInfo(module)) { + is ModuleCreateInfo.CreateSdkInfoWrapper -> { + when (val info = moduleInfo.createSdkInfo) { + is CreateSdkInfo.ExistingEnv -> { + when (val r = info.sdkCreator(false)) { + is Result.Failure -> { + logger.trace { "Failed to create sdk for ${module.name} : ${r.error}" } + } + is Result.Success -> { + val sdk = r.result!! // It can't be null: this is an old buggy API that will be fixed soon + ModuleRootModificationUtil.setModuleSdk(module, sdk) + logger.trace { "SDK creation result for ${module.name} : $sdk" } + } + } + } + is CreateSdkInfo.WillCreateEnv -> { + logger.trace { "${module.name} can't be configured automatically: no venv for ${info.intentionName}" } + } + } + } + is ModuleCreateInfo.SameAs, null -> Unit + } + } + else -> { // Multi-module project, only associate workspace members + val modulesToSdk = modules.filter { PythonSdkUtil.findPythonSdk(it) == null }.associateWith { it.suggestSdk() } + for ((module, sdkSuggestion) in modulesToSdk) { + when (sdkSuggestion) { + is SuggestedSdk.PyProjectIndependent, null -> { + logger.trace { "${module.name} skipped in multimodule project autoconfig" } + } + is SuggestedSdk.SameAs -> { + val parentSdk = PythonSdkUtil.findPythonSdk(sdkSuggestion.parentModule) ?: continue + logger.trace { "${module.name} seeds the same sdk as ${sdkSuggestion.parentModule} : ${parentSdk}" } + ModuleRootModificationUtil.setModuleSdk(module, parentSdk) + } + } + } + } + } +} + + +private sealed interface ModuleCreateInfo { + data class CreateSdkInfoWrapper(val createSdkInfo: CreateSdkInfo, val toolId: ToolId, val moduleDir: Directory?) : ModuleCreateInfo + data class SameAs(val parentModuleName: ModuleName) : ModuleCreateInfo +} + +private val logger = fileLogger() + +/** + * For multiple calls, pull [configuratorsByTool] up not to create it each time + */ +private suspend fun getModuleInfo( + module: Module, + configuratorsByTool: Map = PyProjectSdkConfigurationExtension.createMap(), +): ModuleCreateInfo? = // 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(r.moduleDir) + } + } + is SuggestedSdk.SameAs -> { + ModuleCreateInfo.SameAs(r.parentModule.name) + } + 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(module.guessModuleDir()?.toNioPath()) } + +private fun CreateSdkInfoWithTool.asDTO(moduleDir: Directory?): ModuleCreateInfo = + ModuleCreateInfo.CreateSdkInfoWrapper(createSdkInfo, toolId, moduleDir) diff --git a/python/python-sdk-configurator/backend/src/impl/backendLib.kt b/python/python-sdk-configurator/backend/src/impl/backendLib.kt index 651ee4ca3020..46dc793283f5 100644 --- a/python/python-sdk-configurator/backend/src/impl/backendLib.kt +++ b/python/python-sdk-configurator/backend/src/impl/backendLib.kt @@ -14,28 +14,35 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -private val askUserMutex = Mutex() +private val configureMutex = Mutex() /** - * Same as [configureSdkAskingUser] but in a separate coroutine + * Same as [configureSdk] but in a separate coroutine */ -internal fun configureSdkAskingUserBg(project: Project) { +internal fun configureSdkBg(project: Project, mode: ModuleConfigurationMode) { project.service().scope.launch(Dispatchers.Default) { - configureSdkAskingUser(project) + configureSdk(project, mode) } } /** - * Ask user for list of modules and configure them + * Configure SDKs for [project] in [ModuleConfigurationMode] manner (see its doc for the semantics) */ -internal suspend fun configureSdkAskingUser(project: Project) { +internal suspend fun configureSdk(project: Project, mode: ModuleConfigurationMode) { withContext(Dispatchers.Default) { - askUserMutex.withLock { - val moduleToSuggestedSdk = ModulesSdkConfigurator.create(project) - val modulesDTO = moduleToSuggestedSdk.modulesDTO - if (modulesDTO.isNotEmpty()) { - // No need to send empty list - SHOW_SDK_CONFIG_UI_TOPIC.sendToClient(project, ModulesDTO(modulesDTO)) + configureMutex.withLock { + when (mode) { + ModuleConfigurationMode.AUTOMATIC -> { + configureSdkAutomatically(project) + } + ModuleConfigurationMode.INTERACTIVE -> { + val moduleToSuggestedSdk = ModulesSdkConfigurator.create(project) + val modulesDTO = moduleToSuggestedSdk.modulesDTO + if (modulesDTO.isNotEmpty()) { + // No need to send empty list + SHOW_SDK_CONFIG_UI_TOPIC.sendToClient(project, ModulesDTO(modulesDTO)) + } + } } } } diff --git a/python/python-sdk-configurator/backend/src/impl/platformBridge/ConfigureSDKAction.kt b/python/python-sdk-configurator/backend/src/impl/platformBridge/ConfigureSDKAction.kt index ebc8fd653827..64c0dc47af20 100644 --- a/python/python-sdk-configurator/backend/src/impl/platformBridge/ConfigureSDKAction.kt +++ b/python/python-sdk-configurator/backend/src/impl/platformBridge/ConfigureSDKAction.kt @@ -3,8 +3,8 @@ package com.intellij.python.sdkConfigurator.backend.impl.platformBridge import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.python.sdkConfigurator.backend.impl.configureSdkAskingUser -import com.intellij.python.sdkConfigurator.backend.impl.configureSdkAskingUserBg +import com.intellij.python.sdkConfigurator.backend.impl.ModuleConfigurationMode +import com.intellij.python.sdkConfigurator.backend.impl.configureSdkBg import com.intellij.python.sdkConfigurator.common.enableSDKAutoConfigurator internal class ConfigureSDKAction : AnAction() { @@ -14,7 +14,7 @@ internal class ConfigureSDKAction : AnAction() { if (!enableSDKAutoConfigurator) { return } - configureSdkAskingUserBg(project) + configureSdkBg(project, ModuleConfigurationMode.INTERACTIVE) } override fun update(e: AnActionEvent) { diff --git a/python/python-sdk-configurator/backend/src/impl/platformBridge/ModelRebuiltListenerImpl.kt b/python/python-sdk-configurator/backend/src/impl/platformBridge/ModelRebuiltListenerImplToConfigureSdk.kt similarity index 70% rename from python/python-sdk-configurator/backend/src/impl/platformBridge/ModelRebuiltListenerImpl.kt rename to python/python-sdk-configurator/backend/src/impl/platformBridge/ModelRebuiltListenerImplToConfigureSdk.kt index 41a9fdde637f..accba645ba55 100644 --- a/python/python-sdk-configurator/backend/src/impl/platformBridge/ModelRebuiltListenerImpl.kt +++ b/python/python-sdk-configurator/backend/src/impl/platformBridge/ModelRebuiltListenerImplToConfigureSdk.kt @@ -3,10 +3,11 @@ package com.intellij.python.sdkConfigurator.backend.impl.platformBridge import com.intellij.openapi.extensions.ExtensionNotApplicableException import com.intellij.openapi.project.Project import com.intellij.python.pyproject.model.api.ModelRebuiltListener -import com.intellij.python.sdkConfigurator.backend.impl.configureSdkAskingUserBg +import com.intellij.python.sdkConfigurator.backend.impl.ModuleConfigurationMode +import com.intellij.python.sdkConfigurator.backend.impl.configureSdkBg import com.intellij.python.sdkConfigurator.common.enableSDKAutoConfigurator -internal class ModelRebuiltListenerImpl : ModelRebuiltListener { +internal class ModelRebuiltListenerImplToConfigureSdk : ModelRebuiltListener { init { if (!enableSDKAutoConfigurator) { throw ExtensionNotApplicableException.create() @@ -14,7 +15,7 @@ internal class ModelRebuiltListenerImpl : ModelRebuiltListener { } override fun modelRebuilt(project: Project) { - configureSdkAskingUserBg(project) + configureSdkBg(project, mode = ModuleConfigurationMode.AUTOMATIC) } } diff --git a/python/python-sdk-configurator/backend/src/impl/rpcBridge/SdkConfiguratorApiImpl.kt b/python/python-sdk-configurator/backend/src/impl/rpcBridge/SdkConfiguratorApiImpl.kt index 557f7cc97e48..e8a0e205d35b 100644 --- a/python/python-sdk-configurator/backend/src/impl/rpcBridge/SdkConfiguratorApiImpl.kt +++ b/python/python-sdk-configurator/backend/src/impl/rpcBridge/SdkConfiguratorApiImpl.kt @@ -2,8 +2,9 @@ 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.ModuleConfigurationMode 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.configureSdk import com.intellij.python.sdkConfigurator.common.impl.ModuleName import com.intellij.python.sdkConfigurator.common.impl.SdkConfiguratorBackEndApi @@ -13,6 +14,6 @@ internal object SdkConfiguratorApiImpl : SdkConfiguratorBackEndApi { } override suspend fun configureAskingUser(projectId: ProjectId) { - configureSdkAskingUser(projectId.findProject()) + configureSdk(projectId.findProject(), mode = ModuleConfigurationMode.INTERACTIVE) } } \ No newline at end of file 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 8c6f032f922a..1f44d9d6e25c 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 @@ -15,7 +15,7 @@ typealias EnvExists = Boolean sealed interface CreateSdkInfo : Comparable { @get:IntentionName val intentionName: String - val sdkCreator: suspend (NeedsConfirmation) -> PyResult + val sdkCreator: suspend (needsConfirmation: NeedsConfirmation) -> PyResult /** * Nullable SDK is only possible when we requested user confirmation but didn't get it. The idea behind this function is to provide @@ -38,12 +38,12 @@ sealed interface CreateSdkInfo : Comparable { data class ExistingEnv( val pythonInfo: PythonInfo, override val intentionName: String, - override val sdkCreator: suspend (NeedsConfirmation) -> PyResult, + override val sdkCreator: suspend (needsConfirmation: NeedsConfirmation) -> PyResult, ) : CreateSdkInfo data class WillCreateEnv( override val intentionName: String, - override val sdkCreator: suspend (NeedsConfirmation) -> PyResult, + override val sdkCreator: suspend (needsConfirmation: NeedsConfirmation) -> PyResult, ) : CreateSdkInfo } @@ -58,7 +58,7 @@ sealed interface EnvCheckerResult { // TODO: Make internal after we drop WSL sdk configurator suspend fun prepareSdkCreator( envChecker: suspend (CheckExistence) -> EnvCheckerResult, - sdkCreator: (EnvExists) -> (suspend (NeedsConfirmation) -> PyResult), + sdkCreator: (EnvExists) -> (suspend (needsConfirmation: NeedsConfirmation) -> PyResult), ): CreateSdkInfo? { var res = envChecker(true) return when (res) {