PY-88229 Rename Python interpreter directly in the project JDK table

Validate the new name across all SDKs and re-point the project/module SDK
references to the renamed SDK. Detach the project SDK during the rename so
only one jdkNameChanged listener updates the project model, avoiding a
recursive updateProjectModel.

GitOrigin-RevId: 12d7790c87b094b29715be3192c8035a683f4b8b
This commit is contained in:
Vitaly Legchilkin
2026-06-12 21:43:11 +00:00
committed by intellij-monorepo-bot
parent ca71cae476
commit fe66dc2f3b
8 changed files with 275 additions and 28 deletions
+8
View File
@@ -241,6 +241,14 @@ jvm_library(
"//platform/workspace/jps:jps_test_lib",
"//platform/workspace/storage",
"//platform/workspace/storage:storage_test_lib",
"//platform/testFramework/junit5",
"//platform/testFramework/junit5:junit5_test_lib",
"//python/python-test-env/junit5",
"//python/python-test-env/junit5:junit5_test_lib",
"//python/junit5Tests-framework:community-junit5Tests-framework",
"//python/junit5Tests-framework:community-junit5Tests-framework_test_lib",
"//libraries/kotlinx/coroutines/core",
"//libraries/kotlinx/coroutines/core:core_test_lib",
],
)
### auto-generated section `build intellij.python.sdk.tests` end
@@ -45,6 +45,10 @@
<orderEntry type="module" module-name="intellij.platform.workspace.jps" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.workspace.storage" scope="TEST" />
<orderEntry type="module" module-name="intellij.python.sdk" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.testFramework.junit5" scope="TEST" />
<orderEntry type="module" module-name="intellij.python.test.env.junit5" scope="TEST" />
<orderEntry type="module" module-name="intellij.python.community.junit5Tests.framework" scope="TEST" />
<orderEntry type="module" module-name="intellij.libraries.kotlinx.coroutines.core" scope="TEST" />
</component>
<component name="TestModuleProperties" production-module="intellij.python.sdk" />
</module>
@@ -72,6 +72,10 @@ python.configure.interpreter.action=Configure a Python interpreter\u2026
python.configuring.interpreter.progress=Configuring a python interpreter
python.configuring.interpreter.progress.title=Configuring a Python Interpreter
# Rename interpreter
python.sdk.rename.interpreter.name.already.exists=An interpreter named ''{0}'' already exists
python.sdk.rename.interpreter.not.found=Interpreter ''{0}'' was not found
# Skeletons generator
dialog.message.broken.home.path.for=Broken home path for {0}
@@ -0,0 +1,74 @@
// 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.sdk
import com.intellij.openapi.module.ModuleManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.projectRoots.ProjectJdkTable
import com.intellij.openapi.roots.ModuleRootManager
import com.intellij.openapi.roots.ModuleRootModificationUtil
import com.intellij.openapi.roots.ProjectRootManager
import com.intellij.util.concurrency.annotations.RequiresWriteLock
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.sdk.impl.PySdkBundle.message
import org.jetbrains.annotations.ApiStatus.Internal
/**
* Renames the SDK currently registered as [oldName] to [newName] and keeps this project's references pointing at it.
*
* Returns a failure if [newName] is already used by another SDK; on success the project SDK and every module SDK dependency that
* referenced the SDK keep pointing at it.
*
* Renaming the SDK fires `jdkNameChanged`. When the SDK is referenced by BOTH the project SDK and a module SDK dependency, two
* listeners (`ProjectRootManagerImpl` + `ModuleDependencyIndexImpl`) each call `updateProjectModel`, and the multiverse context
* provider re-pumps the message bus between them, producing a recursive `updateProjectModel` that aborts the reference rewrite and
* drops the interpreter's association (PY-88229). To avoid that, the project SDK reference is detached for the duration of the rename
* so only one listener updates the project model; it is always restored afterwards (even if the rename fails), so the project never
* ends up without an interpreter. The project SDK and explicit module SDK references are then re-pointed to the renamed SDK.
*/
@Internal
@RequiresWriteLock
fun Project.renameSdk(oldName: String, newName: String): PyResult<Unit> {
val jdkTable = ProjectJdkTable.getInstance()
val sdk = jdkTable.findJdk(oldName)
?: return PyResult.localizedError(message("python.sdk.rename.interpreter.not.found", oldName))
if (oldName == newName) {
return PyResult.success(Unit)
}
if (jdkTable.findJdk(newName) != null) {
return PyResult.localizedError(message("python.sdk.rename.interpreter.name.already.exists", newName))
}
val projectRootManager = ProjectRootManager.getInstance(this)
// Capture modules whose explicit (non-inherited) SDK is this one; inherited modules follow the project SDK and need no action.
val modulesWithExplicitSdk = ModuleManager.getInstance(this).modules.filter { module ->
val rootManager = ModuleRootManager.getInstance(module)
!rootManager.isSdkInherited && rootManager.sdk === sdk
}
val isProjectSdk = projectRootManager.projectSdk?.name == oldName
if (isProjectSdk) {
projectRootManager.projectSdk = null
}
try {
sdk.sdkModificator.let {
it.name = newName
it.commitChanges()
}
}
finally {
// Restore the project SDK reference: the renamed SDK on success, the unchanged one if the rename failed.
if (isProjectSdk) {
projectRootManager.projectSdk = sdk
}
}
// Re-point the explicit module references to the SDK, which is now renamed in place.
for (module in modulesWithExplicitSdk) {
ModuleRootModificationUtil.setModuleSdk(module, sdk)
}
return Result.success(Unit)
}
@@ -2,12 +2,13 @@
<!-- region Generated dependencies - run `Generate Product Layouts` to regenerate -->
<dependencies>
<module name="intellij.python.sdk"/>
<module name="intellij.libraries.junit5"/>
<module name="intellij.libraries.gson"/>
<module name="intellij.libraries.guava"/>
<module name="intellij.libraries.jackson"/>
<module name="intellij.libraries.jackson.databind"/>
<module name="intellij.libraries.junit4"/>
<module name="intellij.libraries.junit5"/>
<module name="intellij.libraries.kotlinx.coroutines.core"/>
<module name="intellij.platform.analysis"/>
<module name="intellij.platform.analysis.impl"/>
<module name="intellij.platform.core"/>
@@ -17,6 +18,8 @@
<module name="intellij.platform.projectModel"/>
<module name="intellij.platform.projectModel.impl"/>
<module name="intellij.platform.remote.core"/>
<module name="intellij.platform.testFramework.junit5"/>
<module name="intellij.python.test.env.junit5"/>
</dependencies>
<!-- endregion -->
</idea-plugin>
@@ -0,0 +1,151 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.python.junit5Tests.env
import com.intellij.openapi.application.edtWriteAction
import com.intellij.openapi.projectRoots.ProjectJdkTable
import com.intellij.openapi.roots.ModuleRootManager
import com.intellij.openapi.roots.ModuleRootModificationUtil
import com.intellij.openapi.roots.ProjectRootManager
import com.intellij.python.junit5Tests.framework.env.PyEnvTestCase
import com.intellij.python.junit5Tests.framework.env.pySdkFixture
import com.intellij.python.test.env.junit5.pyVenvFixture
import com.intellij.testFramework.junit5.fixture.moduleFixture
import com.intellij.testFramework.junit5.fixture.projectFixture
import com.intellij.testFramework.junit5.fixture.tempPathFixture
import com.jetbrains.python.sdk.renameSdk
import com.jetbrains.python.Result
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* Renaming an interpreter registered in the [ProjectJdkTable] must rename it in the table and keep every workspace-model reference
* (the project SDK and any module SDK dependencies) pointing at it (PY-88229: previously these references were left dangling and the
* interpreter showed up as "No interpreter").
*/
@PyEnvTestCase
class PythonSdkRenameTest {
private val projectFixture = projectFixture()
private val moduleAFixture = projectFixture.moduleFixture(tempPathFixture(), addPathToSourceRoot = true)
private val moduleBFixture = projectFixture.moduleFixture(tempPathFixture(), addPathToSourceRoot = true)
// A venv registered in the project JDK table but not associated with any module; each test wires up the references it needs.
private val venvFixture = pySdkFixture().pyVenvFixture(where = tempPathFixture(), addToSdkTable = true)
// A second, independent venv used to check renaming onto an already used name.
private val secondVenvFixture = pySdkFixture().pyVenvFixture(where = tempPathFixture(), addToSdkTable = true)
@Test
fun renameUpdatesProjectSdkReference(): Unit = runBlocking {
val project = projectFixture.get()
val sdk = venvFixture.get()
val oldName = sdk.name
val newName = "$oldName renamed"
edtWriteAction { ProjectRootManager.getInstance(project).projectSdk = sdk }
edtWriteAction { project.renameSdk(oldName, newName) }
assertSdkRenamedInTable(oldName, newName)
assertEquals(newName, ProjectRootManager.getInstance(project).projectSdk?.name, "Project SDK reference must follow the rename")
}
@Test
fun renameUpdatesModuleSdkReferenceWithoutProjectSdk(): Unit = runBlocking {
val project = projectFixture.get()
val module = moduleAFixture.get()
val sdk = venvFixture.get()
val oldName = sdk.name
val newName = "$oldName renamed"
edtWriteAction { ModuleRootModificationUtil.setModuleSdk(module, sdk) }
edtWriteAction { project.renameSdk(oldName, newName) }
assertSdkRenamedInTable(oldName, newName)
assertEquals(newName, ModuleRootManager.getInstance(module).sdk?.name, "Module SDK reference must follow the rename")
assertNull(ProjectRootManager.getInstance(project).projectSdk, "Project SDK must stay unset")
}
@Test
fun renameUpdatesBothProjectAndModuleReferences(): Unit = runBlocking {
val project = projectFixture.get()
val module = moduleAFixture.get()
val sdk = venvFixture.get()
val oldName = sdk.name
val newName = "$oldName renamed"
edtWriteAction {
ProjectRootManager.getInstance(project).projectSdk = sdk
ModuleRootModificationUtil.setModuleSdk(module, sdk)
}
edtWriteAction { project.renameSdk(oldName, newName) }
assertSdkRenamedInTable(oldName, newName)
assertEquals(newName, ProjectRootManager.getInstance(project).projectSdk?.name, "Project SDK reference must follow the rename")
assertEquals(newName, ModuleRootManager.getInstance(module).sdk?.name, "Module SDK reference must follow the rename")
}
@Test
fun renameUpdatesAllModulesSharingTheSameSdk(): Unit = runBlocking {
val project = projectFixture.get()
val moduleA = moduleAFixture.get()
val moduleB = moduleBFixture.get()
val sdk = venvFixture.get()
val oldName = sdk.name
val newName = "$oldName renamed"
edtWriteAction {
ModuleRootModificationUtil.setModuleSdk(moduleA, sdk)
ModuleRootModificationUtil.setModuleSdk(moduleB, sdk)
}
edtWriteAction { project.renameSdk(oldName, newName) }
assertSdkRenamedInTable(oldName, newName)
assertEquals(newName, ModuleRootManager.getInstance(moduleA).sdk?.name, "Module A SDK reference must follow the rename")
assertEquals(newName, ModuleRootManager.getInstance(moduleB).sdk?.name, "Module B SDK reference must follow the rename")
}
@Test
fun renamingToAnExistingNameDoesNotCreateDuplicate(): Unit = runBlocking {
val project = projectFixture.get()
val first = venvFixture.get()
val second = secondVenvFixture.get()
val firstName = first.name
val secondName = second.name
// SDK names must stay unique in the project JDK table, so renaming the second interpreter to the first's name must be rejected
// and leave the table untouched.
val result = edtWriteAction { project.renameSdk(secondName, firstName) }
assertTrue(result is Result.Failure, "Renaming to an already used name must fail")
val jdkTable = ProjectJdkTable.getInstance()
assertEquals(first, jdkTable.findJdk(firstName), "The existing interpreter must keep its name")
assertEquals(second, jdkTable.findJdk(secondName), "The interpreter being renamed must keep its original name")
}
@Test
fun renamingToTheSameNameIsANoOp(): Unit = runBlocking {
val project = projectFixture.get()
val sdk = venvFixture.get()
val name = sdk.name
val result = edtWriteAction { project.renameSdk(name, name) }
assertTrue(result is Result.Success, "Renaming to the same name must be a no-op success")
assertEquals(sdk, ProjectJdkTable.getInstance().findJdk(name), "The interpreter must be left unchanged")
}
@Test
fun renamingAnUnknownSdkFails(): Unit = runBlocking {
val project = projectFixture.get()
val result = edtWriteAction { project.renameSdk("no such interpreter", "another name") }
assertTrue(result is Result.Failure, "Renaming an interpreter that is not in the project JDK table must fail")
}
private fun assertSdkRenamedInTable(oldName: String, newName: String) {
val jdkTable = ProjectJdkTable.getInstance()
assertNull(jdkTable.findJdk(oldName), "Old SDK name must be gone from the project JDK table")
assertEquals(newName, jdkTable.findJdk(newName)?.name, "Renamed SDK must be present under the new name")
}
}
@@ -1,7 +1,6 @@
// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.configuration
import com.intellij.openapi.application.WriteAction
import com.intellij.openapi.module.Module
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.project.Project
@@ -21,35 +20,22 @@ class PythonInterpreterDetailsConfigurable(project: Project,
parentConfigurable: Configurable) : NamedConfigurable<Sdk>() {
private val underlyingConfigurable: Configurable = createPythonInterpreterConfigurable(project, module, sdk, parentConfigurable)
private var initialSdkName: String = sdk.name
private var currentSdkName: @NlsSafe String = sdk.name
override fun isModified(): Boolean = initialSdkName != currentSdkName || underlyingConfigurable.isModified
override fun isModified(): Boolean = underlyingConfigurable.isModified
override fun apply() {
underlyingConfigurable.apply()
if (currentSdkName != initialSdkName) {
WriteAction.run<Throwable> {
val sdkModificator = sdk.sdkModificator
sdkModificator.name = currentSdkName
sdkModificator.commitChanges()
initialSdkName = currentSdkName
}
}
}
override fun getDisplayName(): @NlsSafe String {
return currentSdkName
}
override fun getDisplayName(): @NlsSafe String = sdk.name
override fun setDisplayName(name: String?) {
currentSdkName = name.orEmpty()
}
// The interpreter is renamed via PythonInterpreterMasterDetails' "Rename" action (which mutates the SDK directly), not by inline
// tree editing, so there is nothing to store here.
override fun setDisplayName(name: String?) {}
override fun getEditableObject(): Sdk = sdk
override fun getBannerSlogan(): String = currentSdkName
override fun getBannerSlogan(): String = sdk.name
override fun createOptionsPanel(): JComponent = underlyingConfigurable.createComponent()?.apply { setDefaultBorder() } ?: JPanel()
@@ -10,10 +10,12 @@ import com.intellij.openapi.actionSystem.CommonShortcuts
import com.intellij.openapi.actionSystem.DefaultActionGroup
import com.intellij.openapi.actionSystem.Presentation
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.project.DumbAwareToggleAction
import com.intellij.openapi.projectRoots.ProjectJdkTable
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.roots.OrderRootType
import com.intellij.openapi.ui.InputValidatorEx
@@ -33,6 +35,8 @@ import com.jetbrains.python.sdk.customizeWithSdkValue
import com.jetbrains.python.sdk.isAssociatedWithAnotherModule
import com.jetbrains.python.sdk.legacy.PythonSdkUtil
import com.jetbrains.python.sdk.noInterpreterMarker
import com.jetbrains.python.sdk.renameSdk
import com.jetbrains.python.onFailure
import javax.swing.JTree
import javax.swing.tree.DefaultMutableTreeNode
import javax.swing.tree.DefaultTreeModel
@@ -101,10 +105,7 @@ internal class PythonInterpreterMasterDetails(private val moduleOrProject: Modul
) {
val configurable = (value as? DefaultMutableTreeNode)?.userObject as? PythonInterpreterDetailsConfigurable
val sdk = configurable?.sdk
// The name might have been changed with "Rename" action and stored in `displayName`, while the change not being reflected in `sdk`
// instance yet
val currentSdkName = configurable?.displayName
customizeWithSdkValue(sdk, noInterpreterMarker, nullSdkValue = null, actualSdkName = currentSdkName)
customizeWithSdkValue(sdk, noInterpreterMarker, nullSdkValue = null)
}
}
@@ -207,7 +208,8 @@ internal class PythonInterpreterMasterDetails(private val moduleOrProject: Modul
override fun actionPerformed(e: AnActionEvent) {
val selectedSdk = getSelectedSdk() ?: return
val initialName = selectedSdk.name
val allNames: List<String> = myRoot.children().asSequence().mapNotNull { (it as? MyNode)?.displayName }.toList()
// SDK names must be unique across the whole project JDK table (all SDK types), not only among the interpreters shown here.
val allNames: List<String> = ProjectJdkTable.getInstance().allJdks.map { it.name }
val name = Messages.showInputDialog(
myTree,
PyBundle.message("python.interpreters.rename.interpreter.dialog.message"),
@@ -234,8 +236,23 @@ internal class PythonInterpreterMasterDetails(private val moduleOrProject: Modul
)
// Skip changing the name if either the dialog is cancelled or the name is not changed
if (name == null || name == initialName) return
// Delegate changing the name to the configurable
selectedConfigurable?.displayName = name
ApplicationManager.getApplication().runWriteAction {
// Rename the registered SDK in the project JDK table and re-point the project/module references to it.
project.renameSdk(initialName, name).onFailure {
// The rename dialog already rejects duplicate names, so this is only a defensive fallback.
thisLogger().warn("Cannot rename interpreter '$initialName' to '$name': $it")
return@runWriteAction
}
// `renameSdk` renamed the registered SDK. For an existing interpreter the tree shows a separate editable copy from
// `ProjectSdksModel`; rename it too so that `ProjectSdksModel.apply()` does not revert the change.
if (selectedSdk.name == initialName) {
selectedSdk.sdkModificator.let {
it.name = name
it.commitChanges()
}
}
}
(myTree.model as? DefaultTreeModel)?.nodeChanged(selectedNode)
myTree.revalidate()
}