diff --git a/python/pluginResources/intellij.python.community.impl.xml b/python/pluginResources/intellij.python.community.impl.xml index 95cf2705e8ae..d44d29e0420c 100644 --- a/python/pluginResources/intellij.python.community.impl.xml +++ b/python/pluginResources/intellij.python.community.impl.xml @@ -32,6 +32,7 @@ messages.PyBundle + messages.PyProjectTomlBundle - + @@ -17,6 +18,8 @@ + \ No newline at end of file diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/AutoImportstarter.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/AutoImportstarter.kt new file mode 100644 index 000000000000..4f24f975feae --- /dev/null +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/internal/AutoImportstarter.kt @@ -0,0 +1,29 @@ +package com.intellij.python.pyproject.model.internal + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.registry.Registry +import com.intellij.python.pyproject.model.internal.autoImportBridge.PyProjectAutoImportService +import org.jetbrains.annotations.ApiStatus + + +private val enabled: Boolean get() = Registry.`is`("intellij.python.pyproject.model") + +/** + * Starts autoimport process if [enabled] or simply "skips" to the next step: [notifyModelRebuilt]. + * This method usually called by [com.intellij.python.pyproject.model.internal.platformBridge.PyProjectSyncActivity] except for new projects. + * In this case, it is postponed till project generation (see usages). + * + * This method can only be called once (see [PyProjectAutoImportService.start]) + */ +@ApiStatus.Internal +suspend fun startAutoImportIfNeeded(project: Project) { + if (enabled) { + project.service().start() + } + else { + // User disabled "pyproject.toml -> module" convertion (aka project model rebuilding), but we still need to notify listener, + // so they configure SDK + notifyModelRebuilt(project) + } +} \ 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 bd5b3044c5b7..623471d3e31c 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 @@ -19,7 +19,11 @@ internal class PyProjectAutoImportService(private val project: Project) : Dispos private var projectId: ExternalSystemProjectId? = null - suspend fun start() { + /** + * Starts auto-import (`builds project module on any pyproject.toml` change). To be called only once! + */ + internal suspend fun start() { + assert(projectId == null) { "Already started, do not call second time" } val tracker = getTracker() val projectAware = PyExternalSystemProjectAware.create(project) val projectId = projectAware.projectId 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 630b3e5c7561..64c9e6081e33 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 @@ -1,25 +1,19 @@ package com.intellij.python.pyproject.model.internal.platformBridge -import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.openapi.startup.ProjectActivity -import com.intellij.openapi.util.registry.Registry -import com.intellij.python.pyproject.model.internal.autoImportBridge.PyProjectAutoImportService -import com.intellij.python.pyproject.model.internal.notifyModelRebuilt +import com.intellij.platform.PlatformProjectOpenProcessor +import com.intellij.python.pyproject.model.internal.startAutoImportIfNeeded internal class PyProjectSyncActivity : ProjectActivity { - private val enabled: Boolean get() = Registry.`is`("intellij.python.pyproject.model") override suspend fun execute(project: Project) { if (project.isDefault) return // Service doesn't support default project - if (enabled) { - project.service().start() - } - else { - // User disabled "pyproject.toml -> module" convertion (aka project model rebuilding), but we still need to notify listener, - // so they configure SDK - notifyModelRebuilt(project) + // For newly created projects, lots of files are generated in background by so-called `PyV3` framework. + // This is done in sync. manner, so we can't rebuild project until they finish, and we let them call startAutoImportIfNeeded. + if (!PlatformProjectOpenProcessor.isNewlyCreatedProject(project)) { + startAutoImportIfNeeded(project) } } -} \ No newline at end of file +} 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 92b6a4e0451f..aca6383fcec8 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 @@ -4,10 +4,30 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.NlsSafe import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.platform.backend.workspace.workspaceModel -import com.intellij.platform.workspace.jps.entities.* +import com.intellij.platform.workspace.jps.entities.ContentRootEntity +import com.intellij.platform.workspace.jps.entities.DependencyScope +import com.intellij.platform.workspace.jps.entities.ExcludeUrlEntity +import com.intellij.platform.workspace.jps.entities.ExternalSystemModuleOptionsEntity +import com.intellij.platform.workspace.jps.entities.FacetEntityBuilder +import com.intellij.platform.workspace.jps.entities.InheritedSdkDependency +import com.intellij.platform.workspace.jps.entities.LibraryDependency +import com.intellij.platform.workspace.jps.entities.ModuleDependency +import com.intellij.platform.workspace.jps.entities.ModuleEntity +import com.intellij.platform.workspace.jps.entities.ModuleId +import com.intellij.platform.workspace.jps.entities.ModuleSourceDependency +import com.intellij.platform.workspace.jps.entities.ModuleTypeId +import com.intellij.platform.workspace.jps.entities.SdkDependency +import com.intellij.platform.workspace.jps.entities.SdkId +import com.intellij.platform.workspace.jps.entities.SourceRootEntity +import com.intellij.platform.workspace.jps.entities.SourceRootTypeId +import com.intellij.platform.workspace.jps.entities.exModuleOptions +import com.intellij.platform.workspace.jps.entities.modifyContentRootEntity +import com.intellij.platform.workspace.jps.entities.modifyModuleEntity +import com.intellij.platform.workspace.jps.entities.sdkId import com.intellij.platform.workspace.storage.EntitySource import com.intellij.platform.workspace.storage.ImmutableEntityStorage import com.intellij.platform.workspace.storage.MutableEntityStorage +import com.intellij.platform.workspace.storage.createEntityTreeCopy import com.intellij.platform.workspace.storage.impl.url.toVirtualFileUrl import com.intellij.platform.workspace.storage.url.VirtualFileUrl import com.intellij.platform.workspace.storage.url.VirtualFileUrlManager @@ -17,6 +37,7 @@ import com.intellij.python.pyproject.model.internal.PyProjectTomlBundle import com.intellij.python.pyproject.model.internal.pyProjectToml.FSWalkInfoWithToml import com.intellij.python.pyproject.model.internal.pyProjectToml.getPEP621Deps import com.intellij.python.pyproject.model.spi.ProjectName +import com.intellij.python.pyproject.model.spi.PyModuleDataTransfer import com.intellij.python.pyproject.model.spi.PyProjectTomlProject import com.intellij.python.pyproject.model.spi.Tool import com.intellij.python.pyproject.model.spi.WorkspaceName @@ -49,9 +70,13 @@ internal suspend fun rebuildProjectModel(project: Project, files: FSWalkInfoWith } val newStorage = createEntityStorage(entries, project.workspaceModel.getVirtualFileUrlManager()) + val transfers = PyModuleDataTransfer.EP.extensionList.map { it.beforeRename(project) } val workspaceModel = project.workspaceModel + val oldToNewModuleNames = object { + lateinit var value: Map + } workspaceModel.update(PyProjectTomlBundle.message("action.PyProjectTomlSyncAction.description")) { currentStorage -> // Fake module entity is added by default if nothing was discovered - relocateUserDefinedModuleSdk(currentStorage) { + oldToNewModuleNames.value = relocateUserDefinedModuleSdk(currentStorage) { removeFakeModuleAndConflictingEntities(currentStorage, newStorage.entities(ModuleEntity::class.java)) currentStorage.replaceBySource({ it is PyProjectTomlEntitySource }, newStorage) @@ -62,6 +87,10 @@ internal suspend fun rebuildProjectModel(project: Project, files: FSWalkInfoWith currentStorage.excludeRoot(excludedRoot, modules) } } + + } + for (transfer in transfers) { + transfer.modulesRenamed(oldToNewModuleNames.value) } } } @@ -85,53 +114,45 @@ private fun MutableEntityStorage.excludeRoot(rootToExclude: VirtualFileUrl, modu * * For each module in [storage] stores `sdkId` and `moduleId`, then calls [transfer] and sets `sdkId` for modules with the same id */ -internal fun relocateUserDefinedModuleSdk(storage: MutableEntityStorage, transfer: () -> Unit) { +internal fun relocateUserDefinedModuleSdk(storage: MutableEntityStorage, transfer: () -> Unit): Map { + val oldToNewName = mutableMapOf() // Store SDK val pyModules = storage.entities(ModuleEntity::class.java).filter { it.isPythonModule }.toList() - // Module might be renamed, so we store its path as a last resort - val tomlDirToSdkId = mutableMapOf() - val moduleIdToSdkId = pyModules - .mapNotNull { moduleEntity -> - val sdkId = moduleEntity.sdkId - // Module has no SDK, but might have a facet - ?: moduleEntity.facets.asSequence() - .filter { it.entitySource is PyProjectTomlEntitySource } - .mapNotNull { storage.findFacet(it) } - .map { it.configuration } - .filterIsInstance() - .mapNotNull { storage.findSdkEntity(it.sdk) } - .map { it.symbolicId } - .firstOrNull() - if (sdkId == null) { - return@mapNotNull null - } - val tomlDir = moduleEntity.pyProjectTomlEntity?.dirWithToml - if (tomlDir != null) { - tomlDirToSdkId[tomlDir] = sdkId - } - Pair(moduleEntity.symbolicId, sdkId) - }.toMap() + val moduleToSdkAndFacets = pyModules.map { moduleEntity -> + val facets = moduleEntity.facets + val sdkId = moduleEntity.sdkId + // Module has no SDK, but might have a facet + ?: facets.asSequence() + .filter { it.entitySource is PyProjectTomlEntitySource } + .mapNotNull { storage.findFacet(it) } + .map { it.configuration } + .filterIsInstance() + .mapNotNull { storage.findSdkEntity(it.sdk) } + .map { it.symbolicId } + .firstOrNull() + Pair(ModuleAnchor(moduleEntity), + Triple(moduleEntity.facets.map { it.createEntityTreeCopy() as FacetEntityBuilder }, sdkId, moduleEntity.name)) + } transfer() - // Restore SDKs - for ((moduleId, sdkId) in moduleIdToSdkId.entries) { - val moduleEntity = storage.resolve(moduleId) - if (moduleEntity != null) { - storage.modifyModuleEntity(moduleEntity) { - this.sdkId = sdkId + for (newEntity in storage.entities(ModuleEntity::class.java)) { + val newEntityAnchor = ModuleAnchor(newEntity) + val (facetsToSet, sdkIdToSet, oldName) = moduleToSdkAndFacets.firstOrNull { it.first.sameAs(newEntityAnchor) }?.second ?: continue + storage.modifyModuleEntity(newEntity) { + if (this.sdkId == null) { + this.sdkId = sdkIdToSet + } + for (facetEntityBuilder in facetsToSet) { + facetEntityBuilder.module = this@modifyModuleEntity + facetEntityBuilder.moduleId = newEntity.symbolicId + this@modifyModuleEntity.facets += facetEntityBuilder } } + oldToNewName[oldName] = newEntity.name } - val pyModulesNoSdk = storage.entities(ModuleEntity::class.java).filter { it.isPythonModule && it.sdkId == null } - for (moduleEntity in pyModulesNoSdk) { - val tomlDir = moduleEntity.pyProjectTomlEntity?.dirWithToml ?: continue - val sdkId = tomlDirToSdkId[tomlDir] ?: continue - storage.modifyModuleEntity(moduleEntity) { - this.sdkId = sdkId - } - } + return oldToNewName } /** @@ -357,4 +378,16 @@ private val ModuleEntity.sdkId: SdkId? } } -private val ModuleEntity.isPythonModule: Boolean get() = entitySource is PyProjectTomlEntitySource && type == PYTHON_MODULE_ID \ No newline at end of file +private val ModuleEntity.isPythonModule: Boolean get() = entitySource is PyProjectTomlEntitySource || type == PYTHON_MODULE_ID + +private class ModuleAnchor(moduleEntity: ModuleEntity) { + private val symbolicId = moduleEntity.symbolicId + private val dirWithToml = moduleEntity.pyProjectTomlEntity?.dirWithToml + private val theOnlyContentRoot = moduleEntity.contentRoots.let { if (it.size == 1) it[0] else null } + + fun sameAs(o: ModuleAnchor): Boolean = + symbolicId == o.symbolicId || + symbolicId.name.equals(o.symbolicId.name, ignoreCase = true) || + (dirWithToml != null && dirWithToml == o.dirWithToml) || + (theOnlyContentRoot != null && theOnlyContentRoot.url == o.theOnlyContentRoot?.url) +} \ No newline at end of file diff --git a/python/python-pyproject/src/com/intellij/python/pyproject/model/spi/PyModuleDataTransfer.kt b/python/python-pyproject/src/com/intellij/python/pyproject/model/spi/PyModuleDataTransfer.kt new file mode 100644 index 000000000000..c48730955060 --- /dev/null +++ b/python/python-pyproject/src/com/intellij/python/pyproject/model/spi/PyModuleDataTransfer.kt @@ -0,0 +1,30 @@ +package com.intellij.python.pyproject.model.spi + +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.project.Project +import org.jetbrains.annotations.ApiStatus + +/** + * Modules can be renamed. To update external things (like run configs), implement this EP. + */ +@ApiStatus.Internal +fun interface PyModuleDataTransfer { + companion object { + internal val EP = ExtensionPointName.create("com.intellij.python.pyproject.model.moduleDataTransfer") + } + + /** + * Called *before* all modules in [project] are renamed + */ + suspend fun beforeRename(project: Project): AfterRename +} + + +@ApiStatus.Internal +fun interface AfterRename { + /** + * Modules rename, and [oldToNewModuleNames] are new names. + */ + suspend fun modulesRenamed(oldToNewModuleNames: Map) +} + diff --git a/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/pyproject/PyProjectSyncActivityTest.kt b/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/pyproject/PyProjectSyncActivityTest.kt new file mode 100644 index 000000000000..34b28a505393 --- /dev/null +++ b/python/python-pyproject/test/com/intellij/python/junit5Tests/unit/pyproject/PyProjectSyncActivityTest.kt @@ -0,0 +1,24 @@ +package com.intellij.python.junit5Tests.unit.pyproject + +import com.intellij.ide.impl.OpenProjectTask +import com.intellij.openapi.components.service +import com.intellij.python.pyproject.model.internal.autoImportBridge.PyProjectAutoImportService +import com.intellij.testFramework.common.timeoutRunBlocking +import com.intellij.testFramework.junit5.TestApplication +import com.intellij.testFramework.junit5.fixture.projectFixture +import kotlinx.coroutines.delay +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.seconds + +@TestApplication +internal class PyProjectSyncActivityTest { + private val projectFixture = + projectFixture(openProjectTask = OpenProjectTask().copy(isProjectCreatedWithWizard = true), openAfterCreation = true) + + + @Test + fun testNoAutoRebuildForWizardBasedProject(): Unit = timeoutRunBlocking { + delay(1.seconds) + projectFixture.get().service().start() + } +} diff --git a/python/src/com/jetbrains/python/newProjectWizard/PyV3ProjectBaseGenerator.kt b/python/src/com/jetbrains/python/newProjectWizard/PyV3ProjectBaseGenerator.kt index 39d493920fd2..b84d4b8e0d68 100644 --- a/python/src/com/jetbrains/python/newProjectWizard/PyV3ProjectBaseGenerator.kt +++ b/python/src/com/jetbrains/python/newProjectWizard/PyV3ProjectBaseGenerator.kt @@ -14,6 +14,7 @@ import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.platform.DirectoryProjectGenerator import com.intellij.platform.ProjectGeneratorPeer import com.intellij.platform.ide.progress.withBackgroundProgress +import com.intellij.python.pyproject.model.internal.startAutoImportIfNeeded import com.intellij.util.concurrency.annotations.RequiresEdt import com.jetbrains.python.PyBundle import com.jetbrains.python.Result @@ -70,39 +71,49 @@ abstract class PyV3ProjectBaseGenerator().coroutineScope coroutineScope.launch { - val (sdk, interpreterStatistics) = settings.generateAndGetSdk(module, baseDir, supportsNotEmptyModuleStructure).getOr { - withContext(Dispatchers.EDT) { - uiServices.errorSink.emit(it.error, project) - } - return@launch // Since we failed to generate a project, we do not need to go any further - } - - withContext(Dispatchers.EDT) { - edtWriteAction { - VirtualFileManager.getInstance().syncRefresh() - } - } - - val pythonVersion = withContext(Dispatchers.IO) { sdk.version } - logPythonNewProjectGenerated(interpreterStatistics, - pythonVersion, - this@PyV3ProjectBaseGenerator, - emptyList()) - - // The project view must be expanded (PY-75909), but it can't be unless it contains some files. - // Either base settings (which create venv) might generate some or type-specific settings (like Django) may. - // So we expand it right after SDK generation, but if there are no files yet, we do it again after project generation - uiServices.expandProjectTreeView(project) - withBackgroundProgress(project, PyBundle.message("python.project.model.progress.title.generating"), cancellable = true) { - typeSpecificSettings.generateProject(module, baseDir, sdk).onFailure { - uiServices.errorSink.emit(it, project) - } - refreshPaths(project, sdk) - } - uiServices.expandProjectTreeView(project) + generateProjectImpl(settings, module, baseDir) + startAutoImportIfNeeded(project) } } + private suspend fun generateProjectImpl( + settings: PyV3BaseProjectSettings, + module: Module, + baseDir: VirtualFile, + ) { + val project = module.project + val (sdk, interpreterStatistics) = settings.generateAndGetSdk(module, baseDir, supportsNotEmptyModuleStructure).getOr { + withContext(Dispatchers.EDT) { + uiServices.errorSink.emit(it.error, project) + } + return // Since we failed to generate a project, we do not need to go any further + } + + withContext(Dispatchers.EDT) { + edtWriteAction { + VirtualFileManager.getInstance().syncRefresh() + } + } + + val pythonVersion = withContext(Dispatchers.IO) { sdk.version } + logPythonNewProjectGenerated(interpreterStatistics, + pythonVersion, + this@PyV3ProjectBaseGenerator, + emptyList()) + + // The project view must be expanded (PY-75909), but it can't be unless it contains some files. + // Either base settings (which create venv) might generate some or type-specific settings (like Django) may. + // So we expand it right after SDK generation, but if there are no files yet, we do it again after project generation + uiServices.expandProjectTreeView(project) + withBackgroundProgress(project, PyBundle.message("python.project.model.progress.title.generating"), cancellable = true) { + typeSpecificSettings.generateProject(module, baseDir, sdk).onFailure { + uiServices.errorSink.emit(it, project) + } + refreshPaths(project, sdk) + } + uiServices.expandProjectTreeView(project) + } + override fun createPeer(): ProjectGeneratorPeer = PyV3GeneratorPeer(baseSettings, typeSpecificUI?.let { Pair(it, typeSpecificSettings) }, uiServices) diff --git a/python/src/com/jetbrains/python/run/PyRunConfigTransfer.kt b/python/src/com/jetbrains/python/run/PyRunConfigTransfer.kt new file mode 100644 index 000000000000..03e4d31b9b20 --- /dev/null +++ b/python/src/com/jetbrains/python/run/PyRunConfigTransfer.kt @@ -0,0 +1,22 @@ +// 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.run + +import com.intellij.execution.RunManager +import com.intellij.openapi.project.Project +import com.intellij.python.pyproject.model.spi.AfterRename +import com.intellij.python.pyproject.model.spi.PyModuleDataTransfer + +// Run configurations store module name that can be changed when user changes pyproject.toml, we change them as well +internal class PyRunConfigTransfer : PyModuleDataTransfer { + override suspend fun beforeRename(project: Project): AfterRename { + val manager = RunManager.getInstance(project) + val moduleNameToConfig = + manager.allConfigurationsList.filterIsInstance>().associateBy { it.moduleName } + return { oldToNameName -> + for ((oldModuleName, config) in moduleNameToConfig) { + val newModuleName = oldToNameName[oldModuleName] ?: continue + config.moduleName = newModuleName + } + } + } +} \ No newline at end of file