[python][sdk] fix lazy loading interpreter lists (PY-82913)

+ make all interpreter flow states nullable to distinguish the loading state from an empty value.

+ remove interpreterLoading flow/flag because it is non-consistent with other interpreter flow states.

+ remove PythonInterpreterComboBox dependency on the model

Merge-request: IJ-MR-174441
Merged-by: Vitaly Legchilkin <Vitaly.Legchilkin@jetbrains.com>
(cherry picked from commit 50832570f4618ba3af40c1216e37eb3431effd80)

# Conflicts:
#	community/python/src/com/jetbrains/python/sdk/add/v2/models.kt
#	python/junit5Tests/tests/com/intellij/python/junit5Tests/env/tests/sdk/addSdk/PythonAddInterpreterModelTest.kt
#	python/junit5Tests/tests/com/intellij/python/junit5Tests/env/tests/sdk/addSdk/PythonLocalAddInterpreterModelTest.kt

GitOrigin-RevId: 467815b09864a9e679f37ac4cea77dc1448c6fa1
This commit is contained in:
Vitaly Legchilkin
2025-09-08 18:18:20 +02:00
committed by intellij-monorepo-bot
parent 1389b1dc24
commit 5e3a8e9bed
11 changed files with 68 additions and 80 deletions

View File

@@ -31,7 +31,7 @@ internal abstract class CustomExistingEnvironmentSelector(
) : PythonExistingEnvironmentConfigurator(model) {
private lateinit var comboBox: PythonInterpreterComboBox
private val existingEnvironments: MutableStateFlow<List<PythonSelectableInterpreter>> = MutableStateFlow(emptyList())
private val existingEnvironments: MutableStateFlow<List<PythonSelectableInterpreter>?> = MutableStateFlow(null)
protected val selectedEnv: ObservableMutableProperty<PythonSelectableInterpreter?> = propertyGraph.property(null)
override fun setupUI(panel: Panel, validationRequestor: DialogValidationRequestor) {
@@ -47,7 +47,6 @@ internal abstract class CustomExistingEnvironmentSelector(
comboBox = pythonInterpreterComboBox(
title = message("sdk.create.custom.existing.env.title", nameTitle),
selectedSdkProperty = selectedEnv,
model = model,
validationRequestor = validationRequestor,
onPathSelected = { path -> addEnvByPath(path) }
) {
@@ -69,6 +68,7 @@ internal abstract class CustomExistingEnvironmentSelector(
comboBox.initialize(
scope = scope,
flow = existingEnvironments.map { existing ->
existing ?: return@map null
val withUniquePath = existing.distinctBy { interpreter -> interpreter.homePath }
sortForExistingEnvironment(withUniquePath, module)
}
@@ -89,7 +89,7 @@ internal abstract class CustomExistingEnvironmentSelector(
private fun addEnvByPath(python: VanillaPythonWithLanguageLevel): PythonSelectableInterpreter {
val interpreter = ManuallyAddedSelectableInterpreter(python)
existingEnvironments.value += interpreter
existingEnvironments.value = (existingEnvironments.value ?: emptyList()) + interpreter
return interpreter
}

View File

@@ -41,7 +41,6 @@ internal abstract class CustomNewEnvironmentCreator(
basePythonComboBox = pythonInterpreterComboBox(
title = message("sdk.create.custom.base.python"),
selectedSdkProperty = model.state.baseInterpreter,
model = model,
validationRequestor = validationRequestor,
onPathSelected = model::addInterpreter,
)

View File

@@ -74,7 +74,6 @@ class EnvironmentCreatorVenv(model: PythonMutableTargetAddInterpreterModel) : Py
versionComboBox = pythonInterpreterComboBox(
title = message("sdk.create.custom.base.python"),
selectedSdkProperty = model.state.baseInterpreter,
model = model,
validationRequestor = validationRequestor,
onPathSelected = model::addInterpreter,
)

View File

@@ -24,7 +24,6 @@ class PythonExistingEnvironmentSelector(model: PythonAddInterpreterModel, privat
comboBox = pythonInterpreterComboBox(
title = message("sdk.create.custom.python.path"),
selectedSdkProperty = model.state.selectedInterpreter,
model = model,
validationRequestor = validationRequestor,
onPathSelected = model::addInterpreter,
)
@@ -32,7 +31,7 @@ class PythonExistingEnvironmentSelector(model: PythonAddInterpreterModel, privat
}
override fun onShown(scope: CoroutineScope) {
val interpretersFlow = model.allInterpreters.map { sortForExistingEnvironment(it, module) }
val interpretersFlow = model.allInterpreters.map { it?.let { sortForExistingEnvironment(it, module) } }
comboBox.initialize(scope, interpretersFlow)
}

View File

@@ -27,7 +27,6 @@ import javax.swing.JLabel
internal class PythonSdkComboBoxWithBrowseButtonEditor(
val comboBox: ComboBox<PythonSelectableInterpreter?>,
val controller: PythonAddInterpreterModel,
onPathSelected: (String) -> Unit,
) : ComboBoxEditor {
private val component = SimpleColoredComponent()
@@ -104,7 +103,7 @@ internal class PythonSdkComboBoxWithBrowseButtonEditor(
if (_item == anObject) return
_item = anObject
component.clear()
component.customizeForPythonInterpreter(controller.interpreterLoading.value, anObject as? PythonSelectableInterpreter)
component.customizeForPythonInterpreter(isBusy, anObject as? PythonSelectableInterpreter)
}
fun setBusy(busy: Boolean) {
@@ -114,7 +113,7 @@ internal class PythonSdkComboBoxWithBrowseButtonEditor(
comboBox.isEnabled = !isBusy
component.clear()
(item as? PythonSelectableInterpreter).takeIf { !busy }.let {
component.customizeForPythonInterpreter(controller.interpreterLoading.value, it)
component.customizeForPythonInterpreter(busy, it)
}
}

View File

@@ -112,7 +112,6 @@ internal class PythonSdkPanelBuilderAndSdkCreator(
pythonBaseVersionComboBox = pythonInterpreterComboBox(
title = message("sdk.create.python.version"),
selectedSdkProperty = model.state.baseInterpreter,
model = model,
validationRequestor = validationRequestor,
onPathSelected = model::addInterpreter
) {

View File

@@ -195,10 +195,6 @@ internal data class HatchFormFields(
val hatchError: ObservableMutableProperty<PyError?>,
) {
fun onShown(scope: CoroutineScope, model: PythonMutableTargetAddInterpreterModel, state: AddInterpreterState, isFilterOnlyExisting: Boolean) {
basePythonComboBox?.let { comboBox ->
model.interpreterLoading.onEach { comboBox.setBusy(it) }.launchIn(scope + Dispatchers.EDT)
}
model.hatchEnvironmentsResult.onEach { environmentsResult ->
hatchError.set((environmentsResult as? Result.Failure)?.error)
@@ -238,7 +234,6 @@ internal fun Panel.buildHatchFormFields(
basePythonComboBox = pythonInterpreterComboBox(
title = message("sdk.create.custom.base.python"),
selectedSdkProperty = model.state.baseInterpreter,
model = model,
validationRequestor = validationRequestor,
onPathSelected = model::addInterpreter,
)

View File

@@ -69,24 +69,25 @@ abstract class PythonAddInterpreterModel(
open val state: AddInterpreterState = AddInterpreterState(propertyGraph)
val targetEnvironmentConfiguration: TargetEnvironmentConfiguration? = null
internal val knownInterpreters: MutableStateFlow<List<PythonSelectableInterpreter>> = MutableStateFlow(emptyList())
private val _detectedInterpreters: MutableStateFlow<List<PythonSelectableInterpreter>> = MutableStateFlow(emptyList())
val detectedInterpreters: StateFlow<List<PythonSelectableInterpreter>> = _detectedInterpreters
internal val knownInterpreters: MutableStateFlow<List<PythonSelectableInterpreter>?> = MutableStateFlow(null)
private val _detectedInterpreters: MutableStateFlow<List<PythonSelectableInterpreter>?> = MutableStateFlow(null)
val detectedInterpreters: StateFlow<List<PythonSelectableInterpreter>?> = _detectedInterpreters
val manuallyAddedInterpreters: MutableStateFlow<List<PythonSelectableInterpreter>> = MutableStateFlow(emptyList())
private var installable: List<PythonSelectableInterpreter> = emptyList()
val condaEnvironments: MutableStateFlow<List<PyCondaEnv>> = MutableStateFlow(emptyList())
val hatchEnvironmentsResult: MutableStateFlow<PyResult<List<HatchVirtualEnvironment>>?> = MutableStateFlow(null)
lateinit var allInterpreters: StateFlow<List<PythonSelectableInterpreter>>
lateinit var baseInterpreters: StateFlow<List<PythonSelectableInterpreter>>
lateinit var allInterpreters: StateFlow<List<PythonSelectableInterpreter>?>
lateinit var baseInterpreters: StateFlow<List<PythonSelectableInterpreter>?>
val interpreterLoading: MutableStateFlow<Boolean> = MutableStateFlow(true)
val condaEnvironmentsLoading: MutableStateFlow<Boolean> = MutableStateFlow(true)
@TestOnly
@ApiStatus.Internal
fun addDetected(detected: DetectedSelectableInterpreter) {
_detectedInterpreters.value += detected
_detectedInterpreters.value?.let { existing ->
_detectedInterpreters.value = existing + detected
}
}
// If the project is provided, sdks associated with it will be kept in the list of interpreters. If not, then they will be filtered out.
@@ -107,8 +108,6 @@ abstract class PythonAddInterpreterModel(
val existingSelectableInterpreters = getExistingSelectableInterpreters()
knownInterpreters.value = existingSelectableInterpreters
_detectedInterpreters.value = getDetectedSelectableInterpreters(existingSelectableInterpreters)
}.invokeOnCompletion {
this.interpreterLoading.value = false
}
this.allInterpreters = combine(
@@ -116,16 +115,20 @@ abstract class PythonAddInterpreterModel(
detectedInterpreters,
manuallyAddedInterpreters,
) { known, detected, added ->
if (known == null || detected == null) return@combine null
added + known + detected
}.map { it.distinctBy { int -> int.homePath }.sorted() }.stateIn(scope, started = SharingStarted.Eagerly, initialValue = emptyList())
}.map { all ->
all?.distinctBy { int -> int.homePath }?.sorted()
}.stateIn(scope, started = SharingStarted.Eagerly, initialValue = null)
this.baseInterpreters = allInterpreters.map { all ->
all.filter { it.isBasePython() }
all?.filter { it.isBasePython() }
}.mapLatest { allExisting ->
allExisting ?: return@mapLatest null
val existingLanguageLevels = allExisting.map { it.languageLevel }.toSet()
val nonExistingInstallable = installable.filter { it.languageLevel !in existingLanguageLevels }
allExisting + nonExistingInstallable
}.stateIn(scope, started = SharingStarted.Eagerly, initialValue = emptyList())
}.stateIn(scope, started = SharingStarted.Eagerly, initialValue = null)
scope.launch(CoroutineName("Detecting Conda Executable and environments") + Dispatchers.IO) {
@@ -266,32 +269,9 @@ abstract class PythonAddInterpreterModel(
@RequiresEdt
internal fun addInstalledInterpreter(homePath: Path, languageLevel: LanguageLevel): DetectedSelectableInterpreter {
val installedInterpreter = DetectedSelectableInterpreter(homePath.pathString, languageLevel, true)
_detectedInterpreters.value += installedInterpreter
_detectedInterpreters.value = (_detectedInterpreters.value ?: emptyList()) + installedInterpreter
return installedInterpreter
}
/**
* Given [pathToPython] returns either cleaned path (if valid) or null and reports error to [errorSink]
*/
suspend fun getSystemPythonFromSelection(pathToPython: String, errorSink: ErrorSink): SystemPython? {
val result = try {
when (val r = systemPythonService.registerSystemPython(Path(pathToPython))) {
is Result.Failure -> PyResult.failure(r.error)
is Result.Success -> PyResult.success(r.result)
}
}
catch (e: InvalidPathException) {
PyResult.localizedError(e.localizedMessage)
}
return when (result) {
is Result.Success -> result.result
is Result.Failure -> {
errorSink.emit(result.error)
null
}
}
}
}
abstract class PythonMutableTargetAddInterpreterModel(projectPathFlows: ProjectPathFlows) : PythonAddInterpreterModel(projectPathFlows) {
@@ -361,8 +341,8 @@ class PythonLocalAddInterpreterModel(projectPathFlows: ProjectPathFlows) : Pytho
super.initialize(scope)
val mostRecentlyUsedBasePath = PySdkSettings.instance.preferredVirtualEnvBaseSdk
val interpreterToSelect = detectedInterpreters.value.find { it.homePath == mostRecentlyUsedBasePath }
?: baseInterpreters.value.filterIsInstance<ExistingSelectableInterpreter>().maxByOrNull { it.languageLevel }
val interpreterToSelect = detectedInterpreters.value?.find { it.homePath == mostRecentlyUsedBasePath }
?: baseInterpreters.value?.filterIsInstance<ExistingSelectableInterpreter>()?.maxByOrNull { it.languageLevel }
if (interpreterToSelect != null) {
state.baseInterpreter.set(interpreterToSelect)
@@ -481,10 +461,10 @@ class MutableTargetState(propertyGraph: PropertyGraph) : AddInterpreterState(pro
internal val PythonAddInterpreterModel.existingSdks
get() = allInterpreters.value.filterIsInstance<ExistingSelectableInterpreter>().map { it.sdk }
get() = allInterpreters.value?.filterIsInstance<ExistingSelectableInterpreter>()?.map { it.sdk } ?: emptyList()
internal fun PythonAddInterpreterModel.findInterpreter(path: String): PythonSelectableInterpreter? {
return allInterpreters.value.find { it.homePath == path }
return allInterpreters.value?.find { it.homePath == path }
}
internal suspend fun PythonAddInterpreterModel.detectCondaEnvironmentsOrError(errorSink: ErrorSink) {
@@ -508,3 +488,27 @@ internal suspend fun PythonAddInterpreterModel.getBasePath(module: Module?): Pat
?: module?.basePath?.let { Path.of(it) }
?: projectPathFlows.projectPathWithDefault.first()
}
/**
* Given [pathToPython] returns either cleaned path (if valid) or null and reports error to [errorSink]
*/
@ApiStatus.Internal
suspend fun getSystemPythonFromSelection(pathToPython: String, errorSink: ErrorSink): SystemPython? {
val result = try {
when (val r = SystemPythonService().registerSystemPython(Path(pathToPython))) {
is Result.Failure -> PyResult.failure(r.error)
is Result.Success -> PyResult.success(r.result)
}
}
catch (e: InvalidPathException) {
PyResult.localizedError(e.localizedMessage)
}
return when (result) {
is Result.Success -> result.result
is Result.Failure -> {
errorSink.emit(result.error)
null
}
}
}

View File

@@ -22,7 +22,6 @@ import com.jetbrains.python.newProjectWizard.collector.PythonNewProjectWizardCol
import com.jetbrains.python.sdk.add.v2.CustomNewEnvironmentCreator
import com.jetbrains.python.sdk.add.v2.PythonInterpreterSelectionMethod.SELECT_EXISTING
import com.jetbrains.python.sdk.add.v2.PythonMutableTargetAddInterpreterModel
import com.jetbrains.python.sdk.add.v2.PythonSelectableInterpreter
import com.jetbrains.python.sdk.add.v2.PythonSupportedEnvironmentManagers.POETRY
import com.jetbrains.python.sdk.add.v2.PythonSupportedEnvironmentManagers.PYTHON
import com.jetbrains.python.sdk.add.v2.VenvExistenceValidationState.Error
@@ -34,7 +33,6 @@ import com.jetbrains.python.statistics.InterpreterType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -71,13 +69,9 @@ internal class EnvironmentCreatorPoetry(
scope.launch(Dispatchers.IO) {
val moduleDir = model.getBasePath(module).let { VirtualFileManager.getInstance().findFileByNioPath(it) }
val validatedInterpreters = if (moduleDir != null) {
val validatedInterpreters = moduleDir?.let {
PoetryPyProjectTomlPythonVersionsService.instance.validateInterpretersVersions(moduleDir, model.baseInterpreters)
as? StateFlow<List<PythonSelectableInterpreter>> ?: model.baseInterpreters
}
else {
model.baseInterpreters
}
} ?: model.baseInterpreters
withContext(Dispatchers.EDT) {
basePythonComboBox.initialize(scope, validatedInterpreters)

View File

@@ -208,14 +208,14 @@ fun replaceHomePathToTilde(sdkHomePath: @NonNls String): @NlsSafe String {
}
class PythonSdkComboBoxListCellRenderer(val loadingFlow: StateFlow<Boolean>) : ColoredListCellRenderer<PythonSelectableInterpreter?>() {
class PythonSdkComboBoxListCellRenderer(val isLoading: () -> Boolean) : ColoredListCellRenderer<PythonSelectableInterpreter?>() {
override fun getListCellRendererComponent(list: JList<out PythonSelectableInterpreter?>?, value: PythonSelectableInterpreter?, index: Int, selected: Boolean, hasFocus: Boolean): Component {
return super.getListCellRendererComponent(list, value, index, selected, hasFocus)
}
override fun customizeCellRenderer(list: JList<out PythonSelectableInterpreter?>, value: PythonSelectableInterpreter?, index: Int, selected: Boolean, hasFocus: Boolean) {
customizeForPythonInterpreter(loadingFlow.value, value)
customizeForPythonInterpreter(isLoading.invoke(), value)
}
}
@@ -250,12 +250,11 @@ class PythonEnvironmentComboBoxRenderer : ColoredListCellRenderer<Any>() {
internal fun Panel.pythonInterpreterComboBox(
title: @Nls String,
selectedSdkProperty: ObservableMutableProperty<PythonSelectableInterpreter?>, // todo not sdk
model: PythonAddInterpreterModel,
validationRequestor: DialogValidationRequestor,
onPathSelected: (VanillaPythonWithLanguageLevel) -> PythonSelectableInterpreter,
customizer: RowsRange.() -> Unit = {},
): PythonInterpreterComboBox {
val comboBox = PythonInterpreterComboBox(model, onPathSelected, ShowingMessageErrorSync)
val comboBox = PythonInterpreterComboBox(onPathSelected, ShowingMessageErrorSync)
.apply {
setBusy(true)
}
@@ -291,16 +290,15 @@ internal fun Panel.pythonInterpreterComboBox(
}
internal class PythonInterpreterComboBox(
val controller: PythonAddInterpreterModel,
val onPathSelected: (VanillaPythonWithLanguageLevel) -> PythonSelectableInterpreter,
private val errorSink: ErrorSink,
) : ComboBox<PythonSelectableInterpreter?>() {
init {
renderer = PythonSdkComboBoxListCellRenderer(controller.interpreterLoading)
renderer = PythonSdkComboBoxListCellRenderer { isBusy }
val newOnPathSelected: (String) -> Unit = {
runWithModalProgressBlocking(ModalTaskOwner.guess(), message("python.sdk.validating.environment")) {
controller.getSystemPythonFromSelection(it, errorSink)?.let { python ->
getSystemPythonFromSelection(it, errorSink)?.let { python ->
onPathSelected(python).also { interpreter ->
require(isEditable) {
"works only with editable combobox because it doesn't reject non-listed items (the list will be updated later via coroutine)"
@@ -310,20 +308,22 @@ internal class PythonInterpreterComboBox(
}
}
}
editor = PythonSdkComboBoxWithBrowseButtonEditor(this, controller, newOnPathSelected)
editor = PythonSdkComboBoxWithBrowseButtonEditor(this, newOnPathSelected)
}
fun initialize(scope: CoroutineScope, flow: Flow<List<PythonSelectableInterpreter>>) {
controller.interpreterLoading.onEach {
setBusy(it)
}.launchIn(scope + Dispatchers.EDT)
fun initialize(scope: CoroutineScope, flow: Flow<List<PythonSelectableInterpreter>?>) {
flow.onEach { interpreters ->
if (interpreters == null) {
setBusy(true)
return@onEach
}
val selectedItemReminder = selectedItem
removeAllItems()
interpreters.forEach(this::addItem)
selectedItemReminder?.let { selectedItem = it }
setBusy(false)
}.launchIn(scope + Dispatchers.EDT)
}

View File

@@ -156,9 +156,9 @@ class PoetryPyProjectTomlPythonVersionsService : Disposable {
fun validateSdkVersions(moduleFile: VirtualFile, sdks: List<Sdk>): List<Sdk> =
sdks.filter { getVersion(moduleFile).isValid(it.versionString) }
fun validateInterpretersVersions(moduleFile: VirtualFile, interpreters: Flow<List<PythonSelectableInterpreter>>): Flow<List<PythonSelectableInterpreter>> {
fun validateInterpretersVersions(moduleFile: VirtualFile, interpreters: Flow<List<PythonSelectableInterpreter>?>): Flow<List<PythonSelectableInterpreter>?> {
val version = getVersion(moduleFile)
return interpreters.map { list -> list.filter { version.isValid(it.languageLevel) } }
return interpreters.map { list -> list?.filter { version.isValid(it.languageLevel) } }
}
private fun getVersion(moduleFile: VirtualFile): PoetryPythonVersion =