PY-87021: (Part 1, TBC): Postpone project rebuild until project generation finished and transfer run config. settings

1. Project rebuild process disposes modules and kills `PyV3` project generation (i.e. Django). We now skip autoimport for newly generated projects, and PyV3 calls `startAutoImportIfNeeded` after project generation.

2. EP introduced to update run configurations when module gets renamed.

GitOrigin-RevId: 55183da9c1f9d1e96e837e970254443fbaec6b5e
This commit is contained in:
Ilya.Kazakevich
2026-02-05 21:31:45 +00:00
committed by intellij-monorepo-bot
parent c0a421aa98
commit 3da2fb2642
10 changed files with 237 additions and 86 deletions
@@ -32,6 +32,7 @@
<resource-bundle>messages.PyBundle</resource-bundle>
<extensions defaultExtensionNs="com.intellij">
<python.pyproject.model.moduleDataTransfer implementation="com.jetbrains.python.run.PyRunConfigTransfer"/>
<localInspection language="TOML" enabledByDefault="true"
implementationClass="com.jetbrains.python.sdk.poetry.PoetryPackageVersionsInspection"
key="INSP.poetry.package.versions.display.name" bundle="messages.PyBundle" groupKey="INSP.GROUP.python"
@@ -9,7 +9,8 @@
<resource-bundle>messages.PyProjectTomlBundle</resource-bundle>
<extensions defaultExtensionNs="com.intellij">
<registryKey defaultValue="true" description="Load project structure from pyproject.toml" key="intellij.python.pyproject.model" restartRequired="true"/>
<registryKey defaultValue="true" description="Load project structure from pyproject.toml" key="intellij.python.pyproject.model"
restartRequired="true"/>
<postStartupActivity implementation="com.intellij.python.pyproject.model.internal.platformBridge.PyProjectSyncActivity"/>
<statistics.projectUsagesCollector implementation="com.intellij.python.pyproject.statistics.PythonTomlStatsUsagesCollector"/>
@@ -17,6 +18,8 @@
<extensionPoints>
<extensionPoint qualifiedName="com.intellij.python.pyproject.model.tool" interface="com.intellij.python.pyproject.model.spi.Tool"
dynamic="true"/>
<extensionPoint qualifiedName="com.intellij.python.pyproject.model.moduleDataTransfer"
interface="com.intellij.python.pyproject.model.spi.PyModuleDataTransfer" dynamic="true"/>
</extensionPoints>
</idea-plugin>
@@ -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<PyProjectAutoImportService>().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)
}
}
@@ -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
@@ -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<PyProjectAutoImportService>().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)
}
}
}
}
@@ -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<String, String>
}
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<String, String> {
val oldToNewName = mutableMapOf<String, String>()
// 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<VirtualFileUrl, SdkId>()
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<PythonFacetSettings>()
.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<PythonFacetSettings>()
.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
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)
}
@@ -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<PyModuleDataTransfer>("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<String, String>)
}
@@ -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<PyProjectAutoImportService>().start()
}
}
@@ -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<TYPE_SPECIFIC_SETTINGS : PyV3ProjectType
override fun generateProject(project: Project, baseDir: VirtualFile, settings: PyV3BaseProjectSettings, module: Module) {
val coroutineScope = project.service<MyService>().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<PyV3BaseProjectSettings> =
PyV3GeneratorPeer(baseSettings, typeSpecificUI?.let { Pair(it, typeSpecificSettings) }, uiServices)
@@ -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<AbstractPythonRunConfiguration<*>>().associateBy { it.moduleName }
return { oldToNameName ->
for ((oldModuleName, config) in moduleNameToConfig) {
val newModuleName = oldToNameName[oldModuleName] ?: continue
config.moduleName = newModuleName
}
}
}
}