diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties index a5346072a59e..5e4f879d75d1 100644 --- a/python/pluginResources/messages/PyBundle.properties +++ b/python/pluginResources/messages/PyBundle.properties @@ -1204,6 +1204,7 @@ python.add.sdk.panel.name.virtualenv.environment=Virtualenv environment python.add.sdk.panel.name.poetry.environment=Poetry Environment python.add.sdk.wait.for.validation=Wait for the executable validation python.add.sdk.version=Version: {0} +python.add.sdk.already.contains.python.with.version=Already contains Python with version {0} python.add.sdk.conda.executable.path.is.empty=Conda executable path is empty python.add.sdk.conda.executable.path.is.not.found=Conda executable is not found @@ -1714,6 +1715,8 @@ tracecontext.detecting.pip.executable=Detecting Pip Executable tracecontext.detecting.uv.executable=Detecting uv Executable tracecontext.detecting.hatch.executable=Detecting Hatch Executable tracecontext.detecting.hatch.environments=Detecting Hatch Environments +tracecontext.detecting.executable=Detecting {0} Executable +tracecontext.detecting.venv=Detecting venv folder tracecontext.generating.git=Generating git tracecontext.packaging.tool.window=Packaging tool window tracecontext.packages.sdk.controller=Packages SDK Controller diff --git a/python/src/com/jetbrains/python/sdk/add/v2/CustomExistingEnvironmentSelector.kt b/python/src/com/jetbrains/python/sdk/add/v2/CustomExistingEnvironmentSelector.kt index e5c5d2b7c9dc..02c1b8e2f6a8 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/CustomExistingEnvironmentSelector.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/CustomExistingEnvironmentSelector.kt @@ -4,7 +4,7 @@ package com.jetbrains.python.sdk.add.v2 import com.intellij.openapi.application.EDT import com.intellij.openapi.module.Module import com.intellij.openapi.observable.properties.ObservableMutableProperty -import com.intellij.openapi.observable.util.isNotNull +import com.intellij.openapi.observable.util.transform import com.intellij.openapi.ui.validation.DialogValidationRequestor import com.intellij.ui.dsl.builder.Panel import com.jetbrains.python.PyBundle.message @@ -23,7 +23,7 @@ import java.util.* @Internal -internal abstract class CustomExistingEnvironmentSelector( +internal abstract class CustomExistingEnvironmentSelector

( private val name: String, model: PythonMutableTargetAddInterpreterModel

, private val module: Module?, @@ -35,21 +35,14 @@ internal abstract class CustomExistingEnvironmentSelector( private val existingEnvironments: MutableStateFlow>?> = MutableStateFlow(null) protected val selectedEnv: ObservableMutableProperty?> = propertyGraph.property(null) - open suspend fun onBinarySelection(pathOnFileSystem: P): ValidatedPath.Executable

{ - val binaryToExec = model.fileSystem.getBinaryToExec(pathOnFileSystem) - return ValidatedPath.Executable(pathOnFileSystem, binaryToExec.getToolVersion(name)) - } - override fun setupUI(panel: Panel, validationRequestor: DialogValidationRequestor) { with(panel) { - executablePath = validatableExecutableField( - propertyGraph = propertyGraph, + executablePath = validatablePathField( fileSystem = model.fileSystem, - backProperty = executable, + pathValidator = toolState, validationRequestor = validationRequestor, labelText = message("sdk.create.custom.venv.executable.path", name), missingExecutableText = message("sdk.create.custom.venv.missing.text", name), - selectedPathValidator = ::onBinarySelection ) val nameTitle = name.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } @@ -60,7 +53,7 @@ internal abstract class CustomExistingEnvironmentSelector( validationRequestor = validationRequestor, onPathSelected = model::addManuallyAddedInterpreter, ) { - visibleIf(executable.isNotNull()) + visibleIf(toolState.backProperty.transform { it?.validationResult?.successOrNull != null }) } } } @@ -104,7 +97,7 @@ internal abstract class CustomExistingEnvironmentSelector( // return interpreter //} - internal abstract val executable: ObservableMutableProperty?> + internal abstract val toolState: PathValidator> internal abstract val interpreterType: InterpreterType internal abstract suspend fun detectEnvironments(modulePath: Path): List> } \ No newline at end of file diff --git a/python/src/com/jetbrains/python/sdk/add/v2/CustomNewEnvironmentCreator.kt b/python/src/com/jetbrains/python/sdk/add/v2/CustomNewEnvironmentCreator.kt index 03852847a271..8dfe602f6319 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/CustomNewEnvironmentCreator.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/CustomNewEnvironmentCreator.kt @@ -1,7 +1,6 @@ // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.jetbrains.python.sdk.add.v2 -import com.intellij.openapi.observable.properties.ObservableMutableProperty import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.ui.validation.DialogValidationRequestor import com.intellij.platform.ide.progress.ModalTaskOwner @@ -48,15 +47,13 @@ internal abstract class CustomNewEnvironmentCreator( onPathSelected = model::addManuallyAddedInterpreter, ) - executablePath = validatableExecutableField( - propertyGraph = propertyGraph, + executablePath = validatablePathField( fileSystem = model.fileSystem, - backProperty = executable, + pathValidator = toolValidator, validationRequestor = validationRequestor, labelText = message("sdk.create.custom.venv.executable.path", name), missingExecutableText = message("sdk.create.custom.venv.missing.text", name), installAction = createInstallFix(errorSink), - selectedPathValidator = ::onBinarySelection ) row("") { @@ -96,7 +93,7 @@ internal abstract class CustomNewEnvironmentCreator( newSdk.persist() if (module != null) { - if (!model.state.makeAvailableForAllProjects.get()) { + if (!model.venvState.makeAvailableForAllProjects.get()) { newSdk.setAssociationToModule(module) } module.baseDir?.refresh(true, false) @@ -112,7 +109,7 @@ internal abstract class CustomNewEnvironmentCreator( type = interpreterType, target = target.toStatisticsField(), globalSitePackage = false, - makeAvailableToAllProjects = model.state.makeAvailableForAllProjects.get(), + makeAvailableToAllProjects = model.venvState.makeAvailableForAllProjects.get(), previouslyConfigured = false, isWSLContext = false, // todo fix for wsl creationMode = InterpreterCreationMode.CUSTOM @@ -135,7 +132,7 @@ internal abstract class CustomNewEnvironmentCreator( PythonSdkFlavor.clearExecutablesCache() installExecutable(errorSink) runWithModalProgressBlocking(ModalTaskOwner.guess(), message("sdk.create.custom.venv.progress.title.detect.executable")) { - detectExecutable() + toolValidator.autodetectExecutable() } } } @@ -184,7 +181,7 @@ internal abstract class CustomNewEnvironmentCreator( internal abstract val interpreterType: InterpreterType - internal abstract val executable: ObservableMutableProperty?> + internal abstract val toolValidator: ToolValidator

internal open val installationVersion: String? = null @@ -198,8 +195,6 @@ internal abstract class CustomNewEnvironmentCreator( protected abstract suspend fun setupEnvSdk(moduleBasePath: Path, baseSdks: List, basePythonBinaryPath: P?, installPackages: Boolean): PyResult - internal abstract suspend fun detectExecutable() - internal open fun onVenvSelectExisting() {} } diff --git a/python/src/com/jetbrains/python/sdk/add/v2/EnvironmentCreatorPip.kt b/python/src/com/jetbrains/python/sdk/add/v2/EnvironmentCreatorPip.kt index d92b675b695a..1ed8be6c3828 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/EnvironmentCreatorPip.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/EnvironmentCreatorPip.kt @@ -3,25 +3,57 @@ package com.jetbrains.python.sdk.add.v2 import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.observable.properties.ObservableMutableProperty +import com.intellij.openapi.observable.properties.PropertyGraph import com.intellij.openapi.projectRoots.Sdk import com.intellij.platform.eel.LocalEelApi +import com.intellij.platform.eel.provider.localEel import com.jetbrains.python.PyBundle import com.jetbrains.python.errorProcessing.ErrorSink import com.jetbrains.python.errorProcessing.PyResult +import com.jetbrains.python.getOrNull +import com.jetbrains.python.sdk.pipenv.getPipEnvExecutable import com.jetbrains.python.sdk.pipenv.pipEnvPath import com.jetbrains.python.sdk.pipenv.setupPipEnvSdkWithProgressReport import com.jetbrains.python.statistics.InterpreterType +import kotlinx.coroutines.CoroutineScope import java.nio.file.Path -internal class EnvironmentCreatorPip(model: PythonMutableTargetAddInterpreterModel

, errorSink: ErrorSink) : CustomNewEnvironmentCreator

("pipenv", model, errorSink) { +class PipenvState

( + fileSystem: FileSystem

, + propertyGraph: PropertyGraph, +) : ToolState { + val pipenvExecutable: ObservableMutableProperty?> = propertyGraph.property(null) + + val toolValidator: ToolValidator

= ToolValidator( + fileSystem = fileSystem, + toolVersionPrefix = "pipenv", + backProperty = pipenvExecutable, + propertyGraph = propertyGraph, + defaultPathSupplier = { + when (fileSystem) { + is FileSystem.Eel -> { + if (fileSystem.eelApi == localEel) getPipEnvExecutable().getOrNull()?.let { PathHolder.Eel(it) } as P? + else null // getPipEnvExecutable() works only with localEel currently + } + else -> null + } + } + ) + + override fun initialize(scope: CoroutineScope) { + toolValidator.initialize(scope) + } +} + +internal class EnvironmentCreatorPip

(model: PythonMutableTargetAddInterpreterModel

, errorSink: ErrorSink) : CustomNewEnvironmentCreator

("pipenv", model, errorSink) { override val interpreterType: InterpreterType = InterpreterType.PIPENV - override val executable: ObservableMutableProperty?> = model.state.pipenvExecutable + override val toolValidator: ToolValidator

= model.pipenvState.toolValidator override suspend fun savePathToExecutableToProperties(pathHolder: PathHolder?) { if ((model.fileSystem as? FileSystem.Eel)?.eelApi !is LocalEelApi) return val savingPath = (pathHolder as? PathHolder.Eel)?.path - ?: (executable.get()?.pathHolder as? PathHolder.Eel)?.path + ?: (toolValidator.backProperty.get()?.pathHolder as? PathHolder.Eel)?.path savingPath?.let { PropertiesComponent.getInstance().pipEnvPath = it.toString() } @@ -33,8 +65,4 @@ internal class EnvironmentCreatorPip(model: PythonMutableTargetAd else -> PyResult.localizedError(PyBundle.message("target.is.not.supported", basePythonBinaryPath)) } } - - override suspend fun detectExecutable() { - model.detectPipEnvExecutable() - } } \ No newline at end of file diff --git a/python/src/com/jetbrains/python/sdk/add/v2/FileSystem.kt b/python/src/com/jetbrains/python/sdk/add/v2/FileSystem.kt index 723d27574bb1..c80613ff5fba 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/FileSystem.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/FileSystem.kt @@ -15,7 +15,9 @@ import com.intellij.python.community.services.internal.impl.VanillaPythonWithLan import com.intellij.python.community.services.shared.VanillaPythonWithLanguageLevel import com.intellij.python.community.services.systemPython.SystemPython import com.intellij.python.community.services.systemPython.SystemPythonService +import com.jetbrains.python.PyBundle.message import com.jetbrains.python.Result +import com.jetbrains.python.errorProcessing.MessageError import com.jetbrains.python.errorProcessing.PyResult import com.jetbrains.python.getOrLogException import com.jetbrains.python.pathValidation.PlatformAndRoot.Companion.getPlatformAndRoot @@ -35,6 +37,7 @@ import kotlinx.coroutines.withContext import java.nio.file.InvalidPathException import java.nio.file.Path import kotlin.io.path.Path +import kotlin.io.path.exists private val LOG: Logger = fileLogger() @@ -42,6 +45,10 @@ private val LOG: Logger = fileLogger() data class SdkWrapper

(val sdk: Sdk, val homePath: P) +internal class VenvAlreadyExistsError

( + val detectedSelectableInterpreter: DetectedSelectableInterpreter

, +) : MessageError(message("python.add.sdk.already.contains.python.with.version", detectedSelectableInterpreter.languageLevel)) + sealed interface FileSystem

{ val isReadOnly: Boolean @@ -49,8 +56,8 @@ sealed interface FileSystem

{ suspend fun getSystemPythonFromSelection(pathToPython: P): PyResult> - suspend fun validateVenv(homePath: P): ValidatedPath.Folder

- suspend fun suggestVenv(projectPath: Path): ValidatedPath.Folder

+ suspend fun validateVenv(homePath: P): PyResult + suspend fun suggestVenv(projectPath: Path): PyResult

fun wrapSdk(sdk: Sdk): SdkWrapper

suspend fun detectSelectableVenv(): List> fun preferredInterpreterBasePath(): P? = null @@ -75,27 +82,29 @@ sealed interface FileSystem

{ PyResult.localizedError(e.localizedMessage) } - override suspend fun validateVenv(homePath: PathHolder.Eel): ValidatedPath.Folder = withContext(Dispatchers.IO) { - val pythonBinaryPath = homePath.path.resolvePythonBinary()?.let { PathHolder.Eel(it) } - val existingPython = pythonBinaryPath?.let { getSystemPythonFromSelection(it) }?.successOrNull - - val validationResult = if (existingPython == null) { - PyResult.success(Unit) - } - else { - PyResult.failure(VenvAlreadyExistsError(existingPython)) + override suspend fun validateVenv(homePath: PathHolder.Eel): PyResult = withContext(Dispatchers.IO) { + val validationResult = when { + !homePath.path.isAbsolute -> PyResult.localizedError(message("python.sdk.new.error.no.absolute")) + homePath.path.exists() -> { + val pythonBinaryPath = homePath.path.resolvePythonBinary()?.let { PathHolder.Eel(it) } + val existingPython = pythonBinaryPath?.let { getSystemPythonFromSelection(it) }?.successOrNull + if (existingPython == null) { + PyResult.localizedError(message("sdk.create.custom.venv.folder.not.empty")) + } + else { + PyResult.failure(VenvAlreadyExistsError(existingPython)) + } + } + else -> PyResult.success(Unit) } - ValidatedPath.Folder(homePath, validationResult) + validationResult } - override suspend fun suggestVenv(projectPath: Path): ValidatedPath.Folder = withContext(Dispatchers.IO) { + override suspend fun suggestVenv(projectPath: Path): PyResult = withContext(Dispatchers.IO) { val preferedFilePath = PySdkSettings.instance.getPreferredVirtualEnvBasePath(projectPath.toString()) val suggestedVirtualEnvPath = FileUtil.toSystemDependentName(preferedFilePath) - val path = parsePath(suggestedVirtualEnvPath).getOr { - return@withContext ValidatedPath.Folder(null, it) - } - validateVenv(path) + parsePath(suggestedVirtualEnvPath) } override suspend fun getSystemPythonFromSelection(pathToPython: PathHolder.Eel): PyResult> { @@ -174,7 +183,7 @@ sealed interface FileSystem

{ return PyResult.success(PathHolder.Target(raw)) } - override suspend fun validateVenv(homePath: PathHolder.Target): ValidatedPath.Folder = withContext(Dispatchers.IO) { + override suspend fun validateVenv(homePath: PathHolder.Target): PyResult = withContext(Dispatchers.IO) { val pythonBinaryPath = resolvePythonBinary(homePath) val existingPython = getSystemPythonFromSelection(pythonBinaryPath).successOrNull @@ -199,18 +208,17 @@ sealed interface FileSystem

{ PyResult.failure(VenvAlreadyExistsError(existingPython)) } - ValidatedPath.Folder(homePath, validationResult) + validationResult } - override suspend fun suggestVenv(projectPath: Path): ValidatedPath.Folder = withContext(Dispatchers.IO) { + override suspend fun suggestVenv(projectPath: Path): PyResult = withContext(Dispatchers.IO) { val homePathString = when { projectPath.toString().isEmpty() -> pythonLanguageRuntimeConfiguration.userHome else -> joinTargetPaths(pythonLanguageRuntimeConfiguration.userHome, VirtualEnvReader.DEFAULT_VIRTUALENVS_DIR, projectPath.fileName.toString(), fileSeparator = '/') } - val homePath = PathHolder.Target(homePathString) - validateVenv(homePath) + PyResult.success(PathHolder.Target(homePathString)) } private suspend fun registerSystemPython(pathToPython: PathHolder.Target): PyResult> { diff --git a/python/src/com/jetbrains/python/sdk/add/v2/FolderValidator.kt b/python/src/com/jetbrains/python/sdk/add/v2/FolderValidator.kt new file mode 100644 index 000000000000..323b4ce05f8a --- /dev/null +++ b/python/src/com/jetbrains/python/sdk/add/v2/FolderValidator.kt @@ -0,0 +1,96 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.sdk.add.v2 + +import com.intellij.openapi.application.UI +import com.intellij.openapi.observable.properties.ObservableMutableProperty +import com.intellij.openapi.observable.properties.PropertyGraph +import com.intellij.openapi.ui.validation.CHECK_NON_EMPTY +import com.intellij.openapi.ui.validation.CHECK_NO_RESERVED_WORDS +import com.jetbrains.python.PyBundle +import com.jetbrains.python.Result.Failure +import com.jetbrains.python.Result.Success +import com.jetbrains.python.TraceContext +import com.jetbrains.python.errorProcessing.PyResult +import kotlinx.coroutines.* + +class FolderValidator

( + val fileSystem: FileSystem

, + override val backProperty: ObservableMutableProperty?>, + propertyGraph: PropertyGraph, + val defaultPathSupplier: suspend () -> PyResult

, + val pathValidator: suspend (P) -> PyResult, +) : PathValidator> { + override val isDirtyValue: ObservableMutableProperty = propertyGraph.property(true) + override val isValidationInProgress: Boolean + get() = validationJob.isActive + + lateinit var scope: CoroutineScope + private lateinit var validationJob: Deferred + + fun initialize(scope: CoroutineScope) { + this.scope = scope + this.validationJob = autodetectFolderPath() + } + + suspend fun autodetectFolder() { + validationJob.cancelAndJoin() + validationJob = autodetectFolderPath() + } + + + private fun autodetectFolderPath(): Deferred { + return scope.async(TraceContext(PyBundle.message("python.sdk.validating.environment"), scope)) { + withContext(Dispatchers.UI) { isDirtyValue.set(true) } + val pathResult = defaultPathSupplier.invoke() + val path = runValidation(pathResult) + withContext(Dispatchers.UI) { backProperty.set(path) } + }.apply { + invokeOnCompletion { + isDirtyValue.set(false) + } + } + } + + private suspend fun runValidation(pathResult: PyResult

): ValidatedPath.Folder

{ + return when (pathResult) { + is Failure -> ValidatedPath.Folder(null, pathResult) + is Success -> { + val path = pathResult.result + ValidatedPath.Folder(path, pathValidator(path)) + } + } + } + + + override fun validate(input: String) { + scope.launch { + if (input.isEmpty()) { + autodetectFolderPath() + return@launch + } + + validationJob.cancelAndJoin() + validationJob = scope.async { + withContext(Dispatchers.UI) { isDirtyValue.set(true) } + + val validatedFolderPath = withContext(Dispatchers.IO) { + for (validator in arrayOf(CHECK_NON_EMPTY, CHECK_NO_RESERVED_WORDS)) { + validator.curry { input }.validate()?.let { + return@withContext ValidatedPath.Folder

(null, PyResult.localizedError(it.message)) + } + } + + val path = fileSystem.parsePath(input).getOr { error -> + return@withContext ValidatedPath.Folder

(null, error) + } + + ValidatedPath.Folder(path, pathValidator.invoke(path)) + } + withContext(Dispatchers.UI) { backProperty.set(validatedFolderPath) } + } + validationJob.invokeOnCompletion { + isDirtyValue.set(false) + } + } + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/sdk/add/v2/PythonAddCustomInterpreter.kt b/python/src/com/jetbrains/python/sdk/add/v2/PythonAddCustomInterpreter.kt index 7bd1c1bdc7b1..cf3ac8c32aa4 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/PythonAddCustomInterpreter.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/PythonAddCustomInterpreter.kt @@ -31,13 +31,9 @@ import com.jetbrains.python.sdk.add.v2.venv.PythonExistingEnvironmentSelector import kotlinx.coroutines.CoroutineScope import org.jetbrains.annotations.ApiStatus.Internal -class VenvAlreadyExistsError( - val detectedSelectableInterpreter: DetectedSelectableInterpreter

, -) : MessageError("Already contains python installation with version ${detectedSelectableInterpreter.languageLevel}") - class ValidationInfoError(val validationInfo: ValidationInfo) : MessageError(validationInfo.message) -class PythonAddCustomInterpreter

( +internal class PythonAddCustomInterpreter

( val model: PythonMutableTargetAddInterpreterModel

, val module: Module?, private val errorSink: ErrorSink, diff --git a/python/src/com/jetbrains/python/sdk/add/v2/PythonSdkPanelBuilderAndSdkCreator.kt b/python/src/com/jetbrains/python/sdk/add/v2/PythonSdkPanelBuilderAndSdkCreator.kt index 8fb7eb43211d..7f36311a2858 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/PythonSdkPanelBuilderAndSdkCreator.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/PythonSdkPanelBuilderAndSdkCreator.kt @@ -127,18 +127,13 @@ internal class PythonSdkPanelBuilderAndSdkCreator( } rowsRange { - executablePath = validatableExecutableField( - propertyGraph = propertyGraph, + executablePath = validatablePathField( fileSystem = model.fileSystem, - backProperty = model.state.condaExecutable, + pathValidator = model.condaState.toolValidator, validationRequestor = validationRequestor, labelText = message("sdk.create.custom.venv.executable.path", "conda"), missingExecutableText = message("sdk.create.custom.venv.missing.text", "conda"), installAction = createInstallCondaFix(model, errorSink), - selectedPathValidator = { - val binToExec = model.fileSystem.getBinaryToExec(it) - ValidatedPath.Executable(it, binToExec.getToolVersion("conda")) - } ) }.visibleIf(_baseConda) @@ -146,7 +141,7 @@ internal class PythonSdkPanelBuilderAndSdkCreator( row("") { comment("").bindText(venvHint) - }.visibleIf(_projectVenv or (_baseConda and model.state.condaExecutable.isNotNull()) or uvSection.hintVisiblePredicate() or _custom) + }.visibleIf(_projectVenv or (_baseConda and model.condaState.condaExecutable.isNotNull()) or uvSection.hintVisiblePredicate() or _custom) rowsRange { custom.setupUI(this, validationRequestor) diff --git a/python/src/com/jetbrains/python/sdk/add/v2/ToolValidator.kt b/python/src/com/jetbrains/python/sdk/add/v2/ToolValidator.kt new file mode 100644 index 000000000000..187335236f14 --- /dev/null +++ b/python/src/com/jetbrains/python/sdk/add/v2/ToolValidator.kt @@ -0,0 +1,101 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.sdk.add.v2 + +import com.intellij.openapi.application.UI +import com.intellij.openapi.observable.properties.ObservableMutableProperty +import com.intellij.openapi.observable.properties.PropertyGraph +import com.jetbrains.python.PyBundle +import com.jetbrains.python.TraceContext +import com.jetbrains.python.errorProcessing.PyResult +import kotlinx.coroutines.* + + +class ToolValidator

( + val fileSystem: FileSystem

, + val toolVersionPrefix: String, + override val backProperty: ObservableMutableProperty?>, + propertyGraph: PropertyGraph, + val defaultPathSupplier: suspend () -> P?, + val pathValidator: suspend (P) -> PyResult = { fileSystem.getBinaryToExec(it).getToolVersion(toolVersionPrefix) }, +) : PathValidator> { + override val isDirtyValue: ObservableMutableProperty = propertyGraph.property(true) + override val isValidationInProgress: Boolean + get() = validationJob.isActive + + lateinit var scope: CoroutineScope + private lateinit var validationJob: Deferred + + fun initialize(scope: CoroutineScope) { + this.scope = scope + this.validationJob = autodetectExecutableJob() + } + + override fun validate(input: String) { + scope.launch { + if (input.isEmpty()) { + autodetectExecutable() + return@launch + } + validationJob.cancelAndJoin() + validationJob = scope.async { + withContext(Dispatchers.UI) { isDirtyValue.set(true) } + + val exec = withContext(Dispatchers.IO) { + val path = fileSystem.parsePath(input).getOr { error -> + return@withContext ValidatedPath.Executable

(null, error) + } + val validationResult = pathValidator(path) + ValidatedPath.Executable(path, validationResult) + } + + withContext(Dispatchers.UI) { backProperty.set(exec) } + } + validationJob.invokeOnCompletion { + isDirtyValue.set(false) + } + } + } + + + suspend fun autodetectExecutable() { + validationJob.cancelAndJoin() + validationJob = autodetectExecutableJob() + } + + private fun autodetectExecutableJob(): Deferred { + return scope.async(TraceContext(PyBundle.message("tracecontext.detecting.executable", toolVersionPrefix), scope)) { + withContext(Dispatchers.UI) { isDirtyValue.set(true) } + val validatedPath = fileSystem.autodetectWithVersionProbe(toolVersionPrefix, defaultPathSupplier) + withContext(Dispatchers.UI) { backProperty.set(validatedPath) } + }.apply { + invokeOnCompletion { + isDirtyValue.set(false) + } + } + } + + companion object { + + suspend fun

FileSystem

.autodetectWithVersionProbe( + toolVersionPrefix: String, + toolPathSupplier: suspend () -> P?, + ): ValidatedPath.Executable

= withContext(Dispatchers.IO) { + val path = toolPathSupplier.invoke() + val validatedPath = path?.validateToolExecutableByVersionProbe(this@autodetectWithVersionProbe, toolVersionPrefix) + ?: ValidatedPath.Executable( + pathHolder = path, + validationResult = PyResult.localizedError(PyBundle.message("python.sdk.executable.is.not.detected")) + ) + validatedPath + } + + private suspend fun

P.validateToolExecutableByVersionProbe(fileSystem: FileSystem

, toolVersionPrefix: String): ValidatedPath.Executable

{ + val binaryToExec = fileSystem.getBinaryToExec(this) + val validationResult = binaryToExec.getToolVersion(toolVersionPrefix) + return ValidatedPath.Executable( + pathHolder = this, + validationResult = validationResult + ) + } + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/sdk/add/v2/ValidatedPathField.kt b/python/src/com/jetbrains/python/sdk/add/v2/ValidatedPathField.kt index 1d66899ca050..18ca44ab84f6 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/ValidatedPathField.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/ValidatedPathField.kt @@ -7,14 +7,12 @@ import com.intellij.execution.target.getTargetType import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.CustomShortcutSet -import com.intellij.openapi.application.EDT import com.intellij.openapi.fileChooser.FileChooserDescriptor import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory import com.intellij.openapi.observable.properties.ObservableMutableProperty -import com.intellij.openapi.observable.properties.PropertyGraph import com.intellij.openapi.observable.util.and -import com.intellij.openapi.observable.util.isNull import com.intellij.openapi.observable.util.not +import com.intellij.openapi.observable.util.transform import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.ui.TextComponentAccessor @@ -40,11 +38,13 @@ import com.intellij.util.asDisposable import com.jetbrains.python.PyBundle.message import com.jetbrains.python.onFailure import com.jetbrains.python.onSuccess -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import org.jetbrains.annotations.Nls import java.awt.AlphaComposite import java.awt.Component @@ -60,7 +60,17 @@ import javax.swing.event.DocumentEvent import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.math.cos -import kotlin.time.Duration.Companion.minutes + +interface PathValidator> { + val backProperty: ObservableMutableProperty + val isDirtyValue: ObservableMutableProperty + val isValidationInProgress: Boolean + fun validate(input: String) + fun markDirty() { + isDirtyValue.set(true) + backProperty.set(null) + } +} private class ValidationSuccessExtension(val validationInfo: T) : ExtendableTextComponent.Extension { override fun getIcon(hovered: Boolean): Icon = AllIcons.General.GreenCheckmark @@ -136,17 +146,14 @@ private class AnimatedFadingIcon(private val icon: DebounceCounterIcon) : Animat private const val VALIDATION_DELAY = 2000 @OptIn(FlowPreview::class, ExperimentalAtomicApi::class) -class ValidatedPathField>( +internal class ValidatedPathField>( val fileSystem: FileSystem

, - val backProperty: ObservableMutableProperty, + val pathValidator: PathValidator, browseFolderDialogTitle: @Nls String, isFileSelectionMode: Boolean, - val isValidationActiveProperty: ObservableMutableProperty, - val pathValidator: suspend (P) -> V, ) : TextFieldWithBrowseButton() { private lateinit var scope: CoroutineScope private val textInputFlow: MutableStateFlow = MutableStateFlow(null) - private var validationJob: Deferred? = null /** * Single Back Property is shared across multiple forms, @@ -155,49 +162,11 @@ class ValidatedPathField>( */ private val editorMode = AtomicBoolean(false) - /** - * There are several UIs for the same back property, - * this method emulates the validation job for the current field when the user switches to another form. - */ - private fun runJobFor3rdPartyValidation() { - scope.launch { - resetValidationJob { - if (backProperty.get() == null) delay(1.minutes) - } - } - } - - private suspend fun resetValidationJob(block: suspend CoroutineScope.() -> Unit) { - validationJob?.cancelAndJoin() - - validationJob = scope.async(Dispatchers.EDT) { - isEnabled = false - isValidationActiveProperty.set(true) - block() - }.apply { - invokeOnCompletion { - isEnabled = true - isValidationActiveProperty.set(false) - } - } - } - private val validationAction = object : DumbAwareAction(AllIcons.Gutter.SuggestedRefactoringBulb) { fun doValidate() { if (!editorMode.load()) return - scope.launch { - resetValidationJob { - val exec = withContext(Dispatchers.IO) { - val path = fileSystem.parsePath(text).getOr { error -> - //TODO HANDLE PATH ERRORS - return@withContext null - } - pathValidator.invoke(path) - } - backProperty.set(exec) - } - } + pathValidator.validate(text.trim()) } override fun actionPerformed(e: AnActionEvent) { @@ -243,25 +212,26 @@ class ValidatedPathField>( } private fun registerPropertyCallbacks() { - backProperty.afterChange(scope.asDisposable()) { validatedPath -> + pathValidator.backProperty.afterChange(scope.asDisposable()) { validatedPath -> if (validatedPath == null) { - isValidationActiveProperty.set(true) - if (!editorMode.load()) runJobFor3rdPartyValidation() return@afterChange } if (validatedPath.pathHolder != null) { text = validatedPath.pathHolder.toString() } - validationJob?.cancel() + else { + text = "" + } } - isValidationActiveProperty.afterChange(scope.asDisposable()) { isValidationActive -> + pathValidator.isDirtyValue.afterChange(scope.asDisposable()) { isDirtyValue -> with(textField as ExtendableTextComponent) { extensions.forEach { removeExtension(it) } - if (isValidationActive) { - if (validationJob?.isActive == true) { + if (isDirtyValue) { + if (pathValidator.isValidationInProgress) { + isEnabled = false addExtension(ValidationInProgressExtension) } else { @@ -270,10 +240,10 @@ class ValidatedPathField>( } else { editorMode.store(false) - if (browseFolderActionLister != null) { - setButtonVisible(true) - } - backProperty.get()?.validationResult?.let { validationResult -> + browseFolderActionLister?.let { setButtonVisible(true) } + isEnabled = true + + pathValidator.backProperty.get()?.validationResult?.let { validationResult -> validationResult .onFailure { addExtension(ValidationErrorExtension) @@ -290,7 +260,6 @@ class ValidatedPathField>( fun initialize(scope: CoroutineScope) { this.scope = scope registerPropertyCallbacks() - runJobFor3rdPartyValidation() // initial validation is processed by the common v2 model scope.launch { textInputFlow @@ -298,12 +267,14 @@ class ValidatedPathField>( .map { if (it == null) return@map null - if (!editorMode.load() && backProperty.get()?.pathHolder?.toString() != it) { + if (editorMode.load()) { + validationWaitIcon.reset() + } + else if ((pathValidator.backProperty.get()?.pathHolder?.toString() ?: "") != it) { editorMode.store(true) - backProperty.set(null) + pathValidator.markDirty() } - validationWaitIcon.reset() it } .debounce(VALIDATION_DELAY.toLong()) @@ -391,72 +362,21 @@ private fun > Panel.installToolRow( } } -fun

Panel.validatableVenvField( - propertyGraph: PropertyGraph, +internal fun > Panel.validatablePathField( fileSystem: FileSystem

, - backProperty: ObservableMutableProperty?>, + pathValidator: PathValidator, validationRequestor: DialogValidationRequestor, labelText: @Nls String, missingExecutableText: @Nls String?, installAction: ActionLink? = null, - selectedPathValidator: suspend (P) -> ValidatedPath.Folder

, -): ValidatedPathField> { - return validatablePathField( - propertyGraph = propertyGraph, - fileSystem = fileSystem, - backProperty = backProperty, - validationRequestor = validationRequestor, - labelText = labelText, - missingExecutableText = missingExecutableText, - installAction = installAction, - isFileSelectionMode = false, - selectedPathValidator = selectedPathValidator, - ) -} - -fun

Panel.validatableExecutableField( - propertyGraph: PropertyGraph, - fileSystem: FileSystem

, - backProperty: ObservableMutableProperty?>, - validationRequestor: DialogValidationRequestor, - labelText: @Nls String, - missingExecutableText: @Nls String?, - installAction: ActionLink? = null, - selectedPathValidator: suspend (P) -> ValidatedPath.Executable

, -): ValidatedPathField> { - return validatablePathField( - propertyGraph = propertyGraph, - fileSystem = fileSystem, - backProperty = backProperty, - validationRequestor = validationRequestor, - labelText = labelText, - missingExecutableText = missingExecutableText, - installAction = installAction, - isFileSelectionMode = true, - selectedPathValidator = selectedPathValidator, - ) -} - -private fun > Panel.validatablePathField( - propertyGraph: PropertyGraph, - fileSystem: FileSystem

, - backProperty: ObservableMutableProperty, - validationRequestor: DialogValidationRequestor, - labelText: @Nls String, - missingExecutableText: @Nls String?, - installAction: ActionLink? = null, - isFileSelectionMode: Boolean, - selectedPathValidator: suspend (P) -> V, -): ValidatedPathField { - val isValidationActiveProperty = propertyGraph.property(false) + isFileSelectionMode: Boolean = true, +): ValidatedPathField { val validatedPathField = ValidatedPathField( fileSystem = fileSystem, - backProperty = backProperty, + pathValidator = pathValidator, browseFolderDialogTitle = labelText, isFileSelectionMode = isFileSelectionMode, - isValidationActiveProperty = isValidationActiveProperty, - pathValidator = selectedPathValidator, ) missingExecutableText?.let { @@ -465,26 +385,26 @@ private fun > Panel.validatablePathFi missingExecutableText = missingExecutableText, installAction = installAction, validatedPathField = validatedPathField - ).visibleIf(backProperty.isNull().and(isValidationActiveProperty.not())) + ).visibleIf(pathValidator.backProperty.transform { it?.pathHolder == null }.and(pathValidator.isDirtyValue.not())) } row(labelText) { cell(validatedPathField) .align(AlignX.FILL) .validationRequestor(validationRequestor - and WHEN_PROPERTY_CHANGED(isValidationActiveProperty) - and WHEN_PROPERTY_CHANGED(backProperty) + and WHEN_PROPERTY_CHANGED(pathValidator.isDirtyValue) + and WHEN_PROPERTY_CHANGED(pathValidator.backProperty) ) .validationOnInput { component -> if (!component.isVisible) return@validationOnInput null - val pyErrorMessage = backProperty.get()?.validationResult?.errorOrNull?.message + val pyErrorMessage = pathValidator.backProperty.get()?.validationResult?.errorOrNull?.message when { pyErrorMessage != null -> { ValidationInfo(pyErrorMessage) } - isValidationActiveProperty.get() -> { + pathValidator.isDirtyValue.get() -> { ValidationInfo(message("python.add.sdk.wait.for.validation")) } else -> null diff --git a/python/src/com/jetbrains/python/sdk/add/v2/common.kt b/python/src/com/jetbrains/python/sdk/add/v2/common.kt index b7890d0af5da..1091e40aca23 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/common.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/common.kt @@ -196,7 +196,7 @@ internal fun installBaseSdk(sdk: Sdk, existingSdks: List): Sdk? { } -suspend fun setupSdk( +internal suspend fun setupSdk( project: Project?, allSdks: List, fileSystem: FileSystem

, diff --git a/python/src/com/jetbrains/python/sdk/add/v2/conda/CondaExistingEnvironmentSelector.kt b/python/src/com/jetbrains/python/sdk/add/v2/conda/CondaExistingEnvironmentSelector.kt index 86901a0ffa0b..e783de850cc8 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/conda/CondaExistingEnvironmentSelector.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/conda/CondaExistingEnvironmentSelector.kt @@ -17,6 +17,7 @@ import com.intellij.ui.dsl.builder.Panel import com.intellij.ui.dsl.builder.bindItem import com.intellij.util.ui.JBUI import com.jetbrains.python.PyBundle.message +import com.jetbrains.python.Result import com.jetbrains.python.errorProcessing.ErrorSink import com.jetbrains.python.errorProcessing.PyResult import com.jetbrains.python.newProject.collector.InterpreterStatisticsInfo @@ -46,18 +47,14 @@ internal class CondaExistingEnvironmentSelector

(model: PythonAdd override fun setupUI(panel: Panel, validationRequestor: DialogValidationRequestor) { with(panel) { - condaExecutable = validatableExecutableField( - propertyGraph = propertyGraph, + condaExecutable = validatablePathField( fileSystem = model.fileSystem, - backProperty = state.condaExecutable, + pathValidator = model.condaState.toolValidator, validationRequestor = validationRequestor, labelText = message("sdk.create.custom.venv.executable.path", "conda"), missingExecutableText = message("sdk.create.custom.venv.missing.text", "conda"), installAction = createInstallCondaFix(model, errorSink) - ) { - val binaryToExec = model.fileSystem.getBinaryToExec(it) - ValidatedPath.Executable(it, binaryToExec.getToolVersion("conda")) - } + ) rowsRange { row(message("sdk.create.custom.env.creation.type")) { @@ -65,18 +62,28 @@ internal class CondaExistingEnvironmentSelector

(model: PythonAdd items = emptyList(), renderer = CondaEnvComboBoxListCellRenderer() ).withExtendableTextFieldEditor() - .bindItem(state.selectedCondaEnv) + .bindItem(model.condaState.selectedCondaEnv) .validationRequestor( validationRequestor - and WHEN_PROPERTY_CHANGED(state.selectedCondaEnv) - and WHEN_PROPERTY_CHANGED(state.condaExecutable) + and WHEN_PROPERTY_CHANGED(model.modificationCounter) + and WHEN_PROPERTY_CHANGED(model.condaState.selectedCondaEnv) + and WHEN_PROPERTY_CHANGED(model.condaState.condaExecutable) and WHEN_PROPERTY_CHANGED(isReloadLinkVisible) ) .validationOnInput { + if (!it.isVisible) return@validationOnInput null + + val environmentsResult = model.condaState.condaEnvironmentsResult.value when { - !it.isVisible -> null - !isReloadLinkVisible.get() -> ValidationInfo(message("python.add.sdk.panel.wait")).asWarning() - it.selectedItem == null -> ValidationInfo(message("python.sdk.conda.no.env.selected.error")) + environmentsResult == null || !isReloadLinkVisible.get() -> { + ValidationInfo(message("python.add.sdk.panel.wait")).asWarning() + } + environmentsResult is Result.Failure -> { + ValidationInfo(environmentsResult.error.message) + } + it.selectedItem == null -> { + ValidationInfo(message("python.sdk.conda.no.env.selected.error")) + } else -> null } } @@ -95,44 +102,36 @@ internal class CondaExistingEnvironmentSelector

(model: PythonAdd .align(AlignX.RIGHT) .visibleIf(isReloadLinkVisible).component } - }.visibleIf(state.condaExecutable.transform { it?.validationResult?.successOrNull != null }) - } - } - - private fun onReloadCondaEnvironments(scope: CoroutineScope) { - scope.launch(Dispatchers.EDT) { - model.condaEnvironmentsLoading.value = true - model.detectCondaEnvironmentsOrError(errorSink) - model.condaEnvironmentsLoading.value = false + }.visibleIf(model.condaState.condaExecutable.transform { it?.validationResult?.successOrNull != null }) } } override fun onShown(scope: CoroutineScope) { scope.launch(Dispatchers.EDT) { - model.condaEnvironments.collectLatest { environments -> + model.condaState.condaEnvironmentsResult.collectLatest { environmentsResult -> envComboBox.removeAllItems() - environments.forEach(envComboBox::addItem) + environmentsResult?.successOrNull?.forEach(envComboBox::addItem) } } reloadLink.action = object : AbstractAction(message("sdk.create.custom.conda.refresh.envs")) { override fun actionPerformed(e: ActionEvent?) { - onReloadCondaEnvironments(scope) + model.condaState.detectCondaEnvironments() } } - model.condaEnvironmentsLoading.onEach { isLoading -> + model.condaState.condaEnvironmentsLoading.onEach { isLoading -> isReloadLinkVisible.set(!isLoading) }.launchIn(scope + Dispatchers.EDT) envComboBox.displayLoaderWhen( - loading = model.condaEnvironmentsLoading, + loading = model.condaState.condaEnvironmentsLoading, makeTemporaryEditable = true, scope = scope, ) condaExecutable.initialize(scope) condaExecutable.displayLoaderWhen( - loading = model.condaEnvironmentsLoading, + loading = model.condaState.condaEnvironmentsLoading, scope = scope, ) } @@ -142,7 +141,7 @@ internal class CondaExistingEnvironmentSelector

(model: PythonAdd } override fun createStatisticsInfo(target: PythonInterpreterCreationTargets): InterpreterStatisticsInfo { - val identity = model.state.selectedCondaEnv.get()?.envIdentity as? PyCondaEnvIdentity.UnnamedEnv + val identity = model.condaState.selectedCondaEnv.get()?.envIdentity as? PyCondaEnvIdentity.UnnamedEnv val selectedConda = if (identity?.isBase == true) InterpreterType.BASE_CONDA else InterpreterType.CONDAVENV return InterpreterStatisticsInfo( type = selectedConda, diff --git a/python/src/com/jetbrains/python/sdk/add/v2/conda/CondaNewEnvironmentCreator.kt b/python/src/com/jetbrains/python/sdk/add/v2/conda/CondaNewEnvironmentCreator.kt index e14034d8b439..49f3faca8f69 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/conda/CondaNewEnvironmentCreator.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/conda/CondaNewEnvironmentCreator.kt @@ -39,35 +39,31 @@ internal class CondaNewEnvironmentCreator(model: PythonMutableTar } row(message("sdk.create.custom.conda.env.name")) { textField() - .bindText(model.state.newCondaEnvName) // property setter for getOrCreateSdk + .bindText(model.condaState.newCondaEnvName) // property setter for getOrCreateSdk .bindText(model.projectPathFlows.projectName) // default value getter } - condaExecutable = validatableExecutableField( - propertyGraph = propertyGraph, + condaExecutable = validatablePathField( fileSystem = model.fileSystem, - backProperty = model.state.condaExecutable, + pathValidator = model.condaState.toolValidator, validationRequestor = validationRequestor, labelText = message("sdk.create.custom.venv.executable.path", "conda"), missingExecutableText = message("sdk.create.custom.venv.missing.text", "conda"), installAction = createInstallCondaFix(model, errorSink) - ) { - val binaryToExec = model.fileSystem.getBinaryToExec(it) - ValidatedPath.Executable(it, binaryToExec.getToolVersion("conda")) - } + ) } } override fun onShown(scope: CoroutineScope) { condaExecutable.initialize(scope) condaExecutable.displayLoaderWhen( - loading = model.condaEnvironmentsLoading, + loading = model.condaState.condaEnvironmentsLoading, scope = scope, ) } override suspend fun getOrCreateSdk(moduleOrProject: ModuleOrProject): PyResult { - return model.createCondaEnvironment(moduleOrProject, NewCondaEnvRequest.EmptyNamedEnv(pythonVersion.get(), model.state.newCondaEnvName.get())) + return model.createCondaEnvironment(moduleOrProject, NewCondaEnvRequest.EmptyNamedEnv(pythonVersion.get(), model.condaState.newCondaEnvName.get())) } override fun createStatisticsInfo(target: PythonInterpreterCreationTargets): InterpreterStatisticsInfo { diff --git a/python/src/com/jetbrains/python/sdk/add/v2/conda/CondaState.kt b/python/src/com/jetbrains/python/sdk/add/v2/conda/CondaState.kt new file mode 100644 index 000000000000..19805c600732 --- /dev/null +++ b/python/src/com/jetbrains/python/sdk/add/v2/conda/CondaState.kt @@ -0,0 +1,99 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.sdk.add.v2.conda + +import com.intellij.openapi.application.UI +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.diagnostic.fileLogger +import com.intellij.openapi.diagnostic.getOrLogException +import com.intellij.openapi.observable.properties.ObservableMutableProperty +import com.intellij.openapi.observable.properties.PropertyGraph +import com.jetbrains.python.PyBundle.message +import com.jetbrains.python.errorProcessing.ErrorSink +import com.jetbrains.python.errorProcessing.PyResult +import com.jetbrains.python.getOrLogException +import com.jetbrains.python.onFailure +import com.jetbrains.python.sdk.add.v2.* +import com.jetbrains.python.sdk.conda.suggestCondaPath +import com.jetbrains.python.sdk.flavors.conda.PyCondaEnv +import com.jetbrains.python.sdk.flavors.conda.PyCondaEnvIdentity +import com.jetbrains.python.util.ShowingMessageErrorSync +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private val LOG: Logger = fileLogger() + +class CondaState

( + val fileSystem: FileSystem

, + propertyGraph: PropertyGraph, +) : ToolState { + val condaExecutable: ObservableMutableProperty?> = propertyGraph.property(null) + val condaEnvironmentsResult: MutableStateFlow>?> = MutableStateFlow(null) + val condaEnvironmentsLoading: MutableStateFlow = MutableStateFlow(false) + val selectedCondaEnv: ObservableMutableProperty = propertyGraph.property(null) + val baseCondaEnv: ObservableMutableProperty = propertyGraph.property(null) + val newCondaEnvName: ObservableMutableProperty = propertyGraph.property("") + lateinit var scope: CoroutineScope + + val toolValidator: ToolValidator

= ToolValidator( + fileSystem = fileSystem, + toolVersionPrefix = "conda", + backProperty = condaExecutable, + propertyGraph = propertyGraph, + defaultPathSupplier = { + val targetEnvironmentConfiguration = (fileSystem as? FileSystem.Target)?.targetEnvironmentConfiguration + val executor = targetEnvironmentConfiguration.toExecutor() + val suggestedCondaPath = runCatching { + suggestCondaPath(targetCommandExecutor = executor) + }.getOrLogException(LOG) + + val condaPathOnFS = suggestedCondaPath?.let { fileSystem.parsePath(suggestedCondaPath).getOrLogException(LOG) } + condaPathOnFS + } + ) + + override fun initialize(scope: CoroutineScope) { + toolValidator.initialize(scope) + this.scope = scope + + condaExecutable.afterChange { condaExecutable -> + condaEnvironmentsResult.value = null + selectedCondaEnv.set(null) + baseCondaEnv.set(null) + + if (condaExecutable?.validationResult?.successOrNull != null) { + detectCondaEnvironments() + } + } + } + + fun detectCondaEnvironments() { + condaEnvironmentsLoading.value = true + scope.launch(Dispatchers.UI) { + condaEnvironmentsResult.value = updateCondaEnvironments() + }.invokeOnCompletion { + condaEnvironmentsLoading.value = false + } + } + + /** + * Returns error or `null` if no error + */ + private suspend fun updateCondaEnvironments(): PyResult> = withContext(Dispatchers.IO) { + val executable = condaExecutable.get() + if (executable == null) return@withContext PyResult.localizedError(message("python.sdk.conda.no.exec")) + executable.validationResult.getOr { return@withContext it } + + val binaryToExec = executable.pathHolder?.let { fileSystem.getBinaryToExec(it) }!! + val environments = PyCondaEnv.getEnvs(binaryToExec).getOr { return@withContext it } + val baseConda = environments.find { env -> env.envIdentity.let { it is PyCondaEnvIdentity.UnnamedEnv && it.isBase } } + + withContext(Dispatchers.UI) { + baseCondaEnv.set(baseConda) + selectedCondaEnv.set(environments.firstOrNull()) + } + return@withContext PyResult.success(environments) + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/sdk/add/v2/conda/condaUtils.kt b/python/src/com/jetbrains/python/sdk/add/v2/conda/condaUtils.kt index 601ccf3302f6..006d14bc840a 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/conda/condaUtils.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/conda/condaUtils.kt @@ -4,9 +4,6 @@ package com.jetbrains.python.sdk.add.v2.conda import com.intellij.execution.target.TargetEnvironmentConfiguration import com.intellij.execution.target.local.LocalTargetEnvironmentRequest import com.intellij.openapi.application.EDT -import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.diagnostic.fileLogger -import com.intellij.openapi.diagnostic.getOrLogException import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.projectRoots.ProjectJdkTable import com.intellij.openapi.projectRoots.Sdk @@ -14,7 +11,6 @@ import com.intellij.python.community.execService.BinaryToExec import com.intellij.util.concurrency.annotations.RequiresEdt import com.jetbrains.python.PyBundle.message import com.jetbrains.python.errorProcessing.PyResult -import com.jetbrains.python.getOrLogException import com.jetbrains.python.isCondaVirtualEnv import com.jetbrains.python.onSuccess import com.jetbrains.python.sdk.ModuleOrProject @@ -24,18 +20,15 @@ import com.jetbrains.python.sdk.conda.* import com.jetbrains.python.sdk.flavors.conda.NewCondaEnvRequest import com.jetbrains.python.sdk.flavors.conda.PyCondaCommand import com.jetbrains.python.sdk.flavors.conda.PyCondaEnv -import com.jetbrains.python.sdk.flavors.conda.PyCondaEnvIdentity import com.jetbrains.python.sdk.persist import com.jetbrains.python.sdk.setAssociationToModule import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -private val LOG: Logger = fileLogger() +import kotlinx.coroutines.flow.takeWhile @RequiresEdt internal fun PythonAddInterpreterModel<*>.createCondaCommand(): PyResult { val targetEnvironmentConfiguration = (fileSystem as? FileSystem.Target)?.targetEnvironmentConfiguration - val executable = state.condaExecutable.get() ?: return PyResult.localizedError(message("python.sdk.select.conda.path.title")) + val executable = condaState.condaExecutable.get() ?: return PyResult.localizedError(message("python.sdk.select.conda.path.title")) return PyCondaCommand( fullCondaPathOnTarget = executable.pathHolder.toString().convertToPathOnTarget(targetEnvironmentConfiguration), targetConfig = targetEnvironmentConfiguration @@ -67,20 +60,28 @@ internal fun TargetEnvironmentConfiguration?.toExecutor(): TargetCommandExecutor return TargetEnvironmentRequestCommandExecutor(this?.createEnvironmentRequest(project = null) ?: LocalTargetEnvironmentRequest()) } + +internal fun PythonAddInterpreterModel<*>.getBaseCondaOrError(): PyResult { + val baseConda = condaState.baseCondaEnv.get() + return if (baseConda != null) PyResult.success(baseConda) else PyResult.localizedError(message("python.sdk.conda.no.base.env.error")) +} + /** * [base] or selected */ suspend fun PythonAddInterpreterModel<*>.selectCondaEnvironment(base: Boolean): PyResult { + condaState.condaEnvironmentsLoading.takeWhile { it }.collect { } val pyCondaEnv = if (base) { getBaseCondaOrError() } else { - state.selectedCondaEnv.get()?.let { PyResult.success(it) } ?: PyResult.localizedError(message("python.sdk.conda.no.env.selected.error")) + condaState.selectedCondaEnv.get()?.let { PyResult.success(it) } + ?: PyResult.localizedError(message("python.sdk.conda.no.env.selected.error")) } .getOr { return it } val existingSdk = ProjectJdkTable.getInstance().findJdk(pyCondaEnv.envIdentity.userReadableName) if (existingSdk != null && existingSdk.isCondaVirtualEnv) return PyResult.success(existingSdk) - val executable = state.condaExecutable.get() ?: return PyResult.localizedError(message("python.sdk.select.conda.path.title")) + val executable = condaState.condaExecutable.get() ?: return PyResult.localizedError(message("python.sdk.select.conda.path.title")) executable.validationResult.getOr { return it } val sdk = PyCondaCommand( @@ -105,45 +106,4 @@ suspend fun BinaryToExec.getCondaVersion(): PyResult { catch (ex: VersionFormatException) { PyResult.localizedError(ex.localizedMessage) } -} - -/** - * Returns error or `null` if no error - */ -internal suspend fun PythonAddInterpreterModel

.detectCondaEnvironments(): PyResult = withContext(Dispatchers.IO) { - val executable = state.condaExecutable.get() - if (executable == null) return@withContext PyResult.localizedError(message("python.sdk.conda.no.exec")) - executable.validationResult.getOr { return@withContext it } - - val binaryToExec = executable.pathHolder?.let { fileSystem.getBinaryToExec(it) }!! - val environments = PyCondaEnv.getEnvs(binaryToExec).getOr { return@withContext it } - val baseConda = environments.find { env -> env.envIdentity.let { it is PyCondaEnvIdentity.UnnamedEnv && it.isBase } } - - withContext(Dispatchers.EDT) { - condaEnvironments.value = environments - state.baseCondaEnv.set(baseConda) - } - return@withContext PyResult.success(Unit) -} - -internal suspend fun PythonAddInterpreterModel

.detectCondaExecutable(): Unit = withContext(Dispatchers.IO) { - val targetEnvironmentConfiguration = (fileSystem as? FileSystem.Target)?.targetEnvironmentConfiguration - val executor = targetEnvironmentConfiguration.toExecutor() - val suggestedCondaPath = runCatching { - suggestCondaPath(targetCommandExecutor = executor) - }.getOrLogException(LOG) - val condaPathOnFS = suggestedCondaPath?.let { fileSystem.parsePath(suggestedCondaPath).getOrLogException(LOG) } - - val executable = if (condaPathOnFS != null) { - val binaryToExec = fileSystem.getBinaryToExec(condaPathOnFS) - val versionResult = binaryToExec.getCondaVersion() - ValidatedPath.Executable(condaPathOnFS, versionResult) - } - else { - ValidatedPath.Executable

(null, PyResult.localizedError(message("python.add.sdk.conda.executable.path.is.not.found"))) - } - - withContext(Dispatchers.EDT) { - state.condaExecutable.set(executable) - } } \ No newline at end of file diff --git a/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchExistingEnvironmentSelector.kt b/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchExistingEnvironmentSelector.kt index 93aa5d0b240d..623488e68901 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchExistingEnvironmentSelector.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchExistingEnvironmentSelector.kt @@ -1,10 +1,8 @@ // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.jetbrains.python.sdk.add.v2.hatch -import com.intellij.openapi.observable.properties.ObservableMutableProperty import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.ui.validation.DialogValidationRequestor -import com.intellij.python.community.execService.BinOnEel import com.intellij.python.hatch.HatchConfiguration import com.intellij.python.hatch.PythonVirtualEnvironment import com.intellij.python.hatch.resolveHatchWorkingDirectory @@ -24,47 +22,29 @@ import com.jetbrains.python.statistics.InterpreterCreationMode import com.jetbrains.python.statistics.InterpreterType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext internal class HatchExistingEnvironmentSelector( override val model: PythonMutableTargetAddInterpreterModel

, ) : PythonExistingEnvironmentConfigurator

(model) { val interpreterType: InterpreterType = InterpreterType.HATCH - val executable: ObservableMutableProperty?> = model.state.hatchExecutable private lateinit var hatchFormFields: HatchFormFields

override fun setupUI(panel: Panel, validationRequestor: DialogValidationRequestor) { hatchFormFields = panel.buildHatchFormFields( model = model, - hatchEnvironmentProperty = state.selectedHatchEnv, - hatchExecutableProperty = executable, validationRequestor = validationRequestor, isGenerateNewMode = false, ) } override fun onShown(scope: CoroutineScope) { - hatchFormFields.onShown(scope, model, state, isFilterOnlyExisting = true) - executable.afterChange { hatchExecutable -> - if (hatchExecutable?.validationResult?.successOrNull == null) { - model.hatchEnvironmentsResult.value = null - return@afterChange - } - - val binaryToExec = hatchExecutable.pathHolder?.let { model.fileSystem.getBinaryToExec(it) } - ?: return@afterChange - scope.launch(Dispatchers.IO) { - model.detectHatchEnvironments(binaryToExec).also { - model.hatchEnvironmentsResult.value = it - } - } - } + hatchFormFields.onShown(scope, model, isFilterOnlyExisting = true) } override suspend fun getOrCreateSdk(moduleOrProject: ModuleOrProject): PyResult { - val environment = state.selectedHatchEnv.get() + val environment = model.hatchState.selectedHatchEnv.get() val existingHatchVenv = environment?.pythonVirtualEnvironment as? PythonVirtualEnvironment.Existing ?: return Result.failure(HatchUIError.HatchEnvironmentIsNotSelected()) @@ -83,8 +63,8 @@ internal class HatchExistingEnvironmentSelector( } } }.onSuccess { - when (val binaryToExec = executable.get()?.pathHolder) { - is BinOnEel -> HatchConfiguration.persistPathForTarget(hatchExecutablePath = binaryToExec.path) + when (val pathHolder = model.hatchState.hatchExecutable.get()?.pathHolder) { + is PathHolder.Eel -> HatchConfiguration.persistPathForTarget(hatchExecutablePath = pathHolder.path) else -> Unit } } diff --git a/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchNewEnvironmentCreator.kt b/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchNewEnvironmentCreator.kt index 86c07f42e9f9..49d456b3bfff 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchNewEnvironmentCreator.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchNewEnvironmentCreator.kt @@ -7,7 +7,6 @@ import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.roots.ModuleRootModificationUtil import com.intellij.openapi.ui.validation.DialogValidationRequestor import com.intellij.openapi.vfs.VfsUtilCore -import com.intellij.python.community.execService.BinOnEel import com.intellij.python.hatch.HatchConfiguration import com.intellij.python.hatch.HatchVirtualEnvironment import com.intellij.python.hatch.getHatchService @@ -18,28 +17,23 @@ import com.jetbrains.python.errorProcessing.ErrorSink import com.jetbrains.python.errorProcessing.PyResult import com.jetbrains.python.hatch.sdk.createSdk import com.jetbrains.python.onSuccess -import com.jetbrains.python.sdk.add.v2.CustomNewEnvironmentCreator -import com.jetbrains.python.sdk.add.v2.PathHolder -import com.jetbrains.python.sdk.add.v2.PythonMutableTargetAddInterpreterModel -import com.jetbrains.python.sdk.add.v2.ValidatedPath +import com.jetbrains.python.sdk.add.v2.* import com.jetbrains.python.statistics.InterpreterType import kotlinx.coroutines.CoroutineScope import java.nio.file.Path -internal class HatchNewEnvironmentCreator( +internal class HatchNewEnvironmentCreator

( override val model: PythonMutableTargetAddInterpreterModel

, errorSink: ErrorSink, ) : CustomNewEnvironmentCreator

("hatch", model, errorSink) { override val interpreterType: InterpreterType = InterpreterType.HATCH - override val executable: ObservableMutableProperty?> = model.state.hatchExecutable + override val toolValidator: ToolValidator

= model.hatchState.toolValidator private val hatchEnvironmentProperty: ObservableMutableProperty = propertyGraph.property(null) private lateinit var hatchFormFields: HatchFormFields

override fun setupUI(panel: Panel, validationRequestor: DialogValidationRequestor) { hatchFormFields = panel.buildHatchFormFields( model = model, - hatchExecutableProperty = executable, - hatchEnvironmentProperty = hatchEnvironmentProperty, validationRequestor = validationRequestor, isGenerateNewMode = true, installHatchActionLink = createInstallFix(errorSink) @@ -50,17 +44,17 @@ internal class HatchNewEnvironmentCreator( override fun onShown(scope: CoroutineScope) { super.onShown(scope) - hatchFormFields.onShown(scope, model, state, isFilterOnlyExisting = false) + hatchFormFields.onShown(scope, model, isFilterOnlyExisting = false) } override suspend fun savePathToExecutableToProperties(pathHolder: PathHolder?) { - val savingPath = pathHolder ?: executable.get()?.pathHolder ?: return + val savingPath = pathHolder ?: toolValidator.backProperty.get()?.pathHolder ?: return val eelPath = (savingPath as? PathHolder.Eel)?.path ?: return HatchConfiguration.persistPathForTarget(hatchExecutablePath = eelPath) } override suspend fun createPythonModuleStructure(module: Module): PyResult { - val hatchExecutablePath = (executable.get()?.pathHolder as? BinOnEel)?.path + val hatchExecutablePath = (toolValidator.backProperty.get()?.pathHolder as? PathHolder.Eel)?.path ?: return Result.failure(HatchUIError.HatchExecutablePathIsNotValid(null)) val hatchService = module.getHatchService(hatchExecutablePath).getOr { return it } @@ -82,15 +76,15 @@ internal class HatchNewEnvironmentCreator( override suspend fun setupEnvSdk(moduleBasePath: Path, baseSdks: List, basePythonBinaryPath: P?, installPackages: Boolean): PyResult { val hatchEnv = hatchEnvironmentProperty.get()?.hatchEnvironment ?: return Result.failure(HatchUIError.HatchEnvironmentIsNotSelected()) - val basePythonBinaryEelPath = when (basePythonBinaryPath) { + val basePythonBinaryEelPath = when (basePythonBinaryPath) { is PathHolder.Eel -> basePythonBinaryPath.path else -> return PyResult.localizedError(PyBundle.message("target.is.not.supported", basePythonBinaryPath)) } - val hatchExecutablePath = when (val hatchBinary = executable.get()?.pathHolder) { + val hatchExecutablePath = when (val hatchBinary = toolValidator.backProperty.get()?.pathHolder) { is PathHolder.Eel -> hatchBinary.path else -> null } - val hatchService = moduleBasePath.getHatchService(hatchExecutablePath = hatchExecutablePath).getOr { return it } + val hatchService = moduleBasePath.getHatchService(hatchExecutablePath = hatchExecutablePath).getOr { return it } val virtualEnvironment = hatchService.createVirtualEnvironment( basePythonBinaryPath = basePythonBinaryEelPath, @@ -103,8 +97,4 @@ internal class HatchNewEnvironmentCreator( } return createdSdk } - - override suspend fun detectExecutable() { - model.detectHatchExecutable() - } } diff --git a/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchState.kt b/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchState.kt new file mode 100644 index 000000000000..214d8ee85d33 --- /dev/null +++ b/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchState.kt @@ -0,0 +1,79 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.sdk.add.v2.hatch + +import com.intellij.openapi.application.UI +import com.intellij.openapi.observable.properties.ObservableMutableProperty +import com.intellij.openapi.observable.properties.PropertyGraph +import com.intellij.python.community.execService.BinOnEel +import com.intellij.python.community.execService.BinaryToExec +import com.intellij.python.hatch.HatchConfiguration.getOrDetectHatchExecutablePath +import com.intellij.python.hatch.HatchVirtualEnvironment +import com.intellij.python.hatch.getHatchService +import com.jetbrains.python.Result +import com.jetbrains.python.Result.Companion.success +import com.jetbrains.python.errorProcessing.PyResult +import com.jetbrains.python.getOrNull +import com.jetbrains.python.newProjectWizard.projectPath.ProjectPathFlows +import com.jetbrains.python.sdk.add.v2.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.io.path.isDirectory + +class HatchState

( + val fileSystem: FileSystem

, + propertyGraph: PropertyGraph, + val projectPathFlows: ProjectPathFlows, +) : ToolState { + val selectedHatchEnv: ObservableMutableProperty = propertyGraph.property(null) + val hatchEnvironmentsResult: MutableStateFlow>?> = MutableStateFlow(null) + val hatchExecutable: ObservableMutableProperty?> = propertyGraph.property(null) + + val toolValidator: ToolValidator

= ToolValidator( + fileSystem = fileSystem, + toolVersionPrefix = "hatch", + backProperty = hatchExecutable, + propertyGraph = propertyGraph, + defaultPathSupplier = { + when (fileSystem) { + is FileSystem.Eel -> getOrDetectHatchExecutablePath(fileSystem.eelApi).getOrNull()?.let { PathHolder.Eel(it) } as P? + else -> null + } + } + ) + + override fun initialize(scope: CoroutineScope) { + toolValidator.initialize(scope) + + hatchExecutable.afterChange { hatchExecutable -> + if (hatchExecutable?.validationResult?.successOrNull == null) { + hatchEnvironmentsResult.value = null + return@afterChange + } + + val binaryToExec = hatchExecutable.pathHolder?.let { fileSystem.getBinaryToExec(it) } + ?: return@afterChange + scope.launch(Dispatchers.UI) { + hatchEnvironmentsResult.value = detectHatchEnvironments(binaryToExec) + } + } + } + + private suspend fun detectHatchEnvironments(hatchExecutable: BinaryToExec): PyResult> = withContext(Dispatchers.IO) { + val projectPath = projectPathFlows.projectPathWithDefault.first() + val hatchExecutablePath = (hatchExecutable as? BinOnEel)?.path + ?: return@withContext Result.failure(HatchUIError.HatchExecutablePathIsNotValid(hatchExecutable.toString())) + val hatchWorkingDirectory = if (projectPath.isDirectory()) projectPath else projectPath.parent + val hatchService = hatchWorkingDirectory.getHatchService(hatchExecutablePath).getOr { return@withContext it } + + val hatchEnvironments = hatchService.findVirtualEnvironments().getOr { return@withContext it } + val availableEnvironments = when { + hatchWorkingDirectory == projectPath -> hatchEnvironments + else -> HatchVirtualEnvironment.AVAILABLE_ENVIRONMENTS_FOR_NEW_PROJECT + } + success(availableEnvironments) + } +} diff --git a/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchUIComponents.kt b/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchUIComponents.kt index 2b08c0ca9932..6f8d99e7b329 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchUIComponents.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchUIComponents.kt @@ -4,7 +4,6 @@ package com.jetbrains.python.sdk.add.v2.hatch import com.intellij.icons.AllIcons import com.intellij.openapi.application.EDT import com.intellij.openapi.observable.properties.AtomicBooleanProperty -import com.intellij.openapi.observable.properties.ObservableMutableProperty import com.intellij.openapi.observable.util.transform import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.ui.ValidationInfo @@ -101,7 +100,6 @@ private class HatchEnvComboBoxListCellRenderer(val contentFlow: StateFlow Panel.addEnvironmentComboBox( model: PythonAddInterpreterModel

, - hatchEnvironmentProperty: ObservableMutableProperty, validationRequestor: DialogValidationRequestor, isValidateOnlyNotExisting: Boolean, ): ComboBox { @@ -110,9 +108,9 @@ private fun

Panel.addEnvironmentComboBox( lateinit var environmentComboBox: ComboBox row(message("sdk.create.custom.hatch.environment")) { - environmentComboBox = comboBox(emptyList(), HatchEnvComboBoxListCellRenderer(model.hatchEnvironmentsResult)) - .bindItem(hatchEnvironmentProperty) - .validationRequestor(validationRequestor and WHEN_PROPERTY_CHANGED(hatchEnvironmentProperty)) + environmentComboBox = comboBox(emptyList(), HatchEnvComboBoxListCellRenderer(model.hatchState.hatchEnvironmentsResult)) + .bindItem(model.hatchState.selectedHatchEnv) + .validationRequestor(validationRequestor and WHEN_PROPERTY_CHANGED(model.hatchState.selectedHatchEnv)) .validationInfo { component -> environmentAlreadyExists.set(false) when { @@ -139,7 +137,7 @@ private fun

Panel.addEnvironmentComboBox( message = message("sdk.create.custom.hatch.environment.exists"), firstActionLink = ActionLink(message("sdk.create.custom.venv.select.existing.link")) { PythonNewProjectWizardCollector.logExistingVenvFixUsed() - model.state.selectedHatchEnv.set(environmentComboBox.item) + model.hatchState.selectedHatchEnv.set(environmentComboBox.item) model.navigator.navigateTo(newMethod = SELECT_EXISTING, newManager = PythonSupportedEnvironmentManagers.HATCH) }, validationType = ValidationType.ERROR @@ -150,22 +148,18 @@ private fun

Panel.addEnvironmentComboBox( private fun

Panel.addExecutableSelector( model: PythonMutableTargetAddInterpreterModel

, - hatchExecutableProperty: ObservableMutableProperty?>, validationRequestor: DialogValidationRequestor, installHatchActionLink: ActionLink? = null, - binaryValidator: suspend (P) -> ValidatedPath.Executable

, ): ValidatedPathField> { - val executablePath = validatableExecutableField( - propertyGraph = model.propertyGraph, + val executablePath = validatablePathField( fileSystem = model.fileSystem, - backProperty = hatchExecutableProperty, + pathValidator = model.hatchState.toolValidator, validationRequestor = validationRequestor, labelText = message("sdk.create.custom.venv.executable.path", "hatch"), missingExecutableText = message("sdk.create.custom.venv.missing.text", "hatch"), installAction = installHatchActionLink, - selectedPathValidator = binaryValidator ) return executablePath @@ -176,23 +170,23 @@ internal data class HatchFormFields

( val basePythonComboBox: PythonInterpreterComboBox

?, val validatedPathField: ValidatedPathField>, ) { - fun onShown(scope: CoroutineScope, model: PythonMutableTargetAddInterpreterModel

, state: AddInterpreterState

, isFilterOnlyExisting: Boolean) { - model.hatchEnvironmentsResult.onEach { environmentsResult -> + fun onShown(scope: CoroutineScope, model: PythonMutableTargetAddInterpreterModel

, isFilterOnlyExisting: Boolean) { + model.hatchState.hatchEnvironmentsResult.onEach { environmentsResult -> when (environmentsResult) { null -> environmentComboBox.isEnabled = false else -> { environmentComboBox.isEnabled = true environmentComboBox.syncWithEnvs(environmentsResult, isFilterOnlyExisting = isFilterOnlyExisting) - if (environmentsResult.isFailure) state.selectedHatchEnv.set(null) + if (environmentsResult.isFailure) model.hatchState.selectedHatchEnv.set(null) } } }.launchIn(scope + Dispatchers.EDT) with(validatedPathField) { initialize(scope) - backProperty.afterChange { executable -> - if (executable != model.state.hatchExecutable.get()) { - model.state.hatchExecutable.set(executable) + pathValidator.backProperty.afterChange { executable -> + if (executable != model.hatchState.hatchExecutable.get()) { + model.hatchState.hatchExecutable.set(executable) } } } @@ -201,8 +195,6 @@ internal data class HatchFormFields

( internal fun

Panel.buildHatchFormFields( model: PythonMutableTargetAddInterpreterModel

, - hatchEnvironmentProperty: ObservableMutableProperty, - hatchExecutableProperty: ObservableMutableProperty?>, validationRequestor: DialogValidationRequestor, isGenerateNewMode: Boolean = false, installHatchActionLink: ActionLink? = null, @@ -210,20 +202,15 @@ internal fun

Panel.buildHatchFormFields( val executablePath = addExecutableSelector( model, - hatchExecutableProperty, validationRequestor, installHatchActionLink - ) { - val binaryToExec = model.fileSystem.getBinaryToExec(it) - ValidatedPath.Executable(it, binaryToExec.getToolVersion("hatch")) - } + ) var environmentComboBox: ComboBox by lateinitVal() var basePythonComboBox: PythonInterpreterComboBox

? = null rowsRange { environmentComboBox = addEnvironmentComboBox( model = model, - hatchEnvironmentProperty = hatchEnvironmentProperty, validationRequestor = validationRequestor, isValidateOnlyNotExisting = isGenerateNewMode ) @@ -237,7 +224,7 @@ internal fun

Panel.buildHatchFormFields( onPathSelected = model::addManuallyAddedInterpreter, ) } - }.visibleIf(hatchExecutableProperty.transform { it?.validationResult?.successOrNull != null }) + }.visibleIf(model.hatchState.hatchExecutable.transform { it?.validationResult?.successOrNull != null }) return HatchFormFields(environmentComboBox, basePythonComboBox, executablePath) diff --git a/python/src/com/jetbrains/python/sdk/add/v2/models.kt b/python/src/com/jetbrains/python/sdk/add/v2/models.kt index 7be856ce416e..aeabced4c5d2 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/models.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/models.kt @@ -2,21 +2,16 @@ package com.jetbrains.python.sdk.add.v2 import com.intellij.execution.target.FullPathOnTarget -import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.UI import com.intellij.openapi.module.Module import com.intellij.openapi.observable.properties.AtomicProperty -import com.intellij.openapi.observable.properties.GraphProperty import com.intellij.openapi.observable.properties.ObservableMutableProperty import com.intellij.openapi.observable.properties.PropertyGraph import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.vfs.toNioPathOrNull -import com.intellij.platform.eel.provider.localEel -import com.intellij.python.community.execService.BinOnEel -import com.intellij.python.community.execService.BinaryToExec -import com.intellij.python.community.services.shared.* -import com.intellij.python.hatch.HatchConfiguration.getOrDetectHatchExecutablePath -import com.intellij.python.hatch.HatchVirtualEnvironment -import com.intellij.python.hatch.getHatchService +import com.intellij.python.community.services.shared.LanguageLevelHolder +import com.intellij.python.community.services.shared.LanguageLevelWithUiComparator +import com.intellij.python.community.services.shared.UiHolder import com.intellij.python.pyproject.PyProjectToml import com.intellij.util.concurrency.annotations.RequiresEdt import com.jetbrains.python.* @@ -29,23 +24,27 @@ import com.jetbrains.python.errorProcessing.ErrorSink import com.jetbrains.python.errorProcessing.PyResult import com.jetbrains.python.newProjectWizard.projectPath.ProjectPathFlows import com.jetbrains.python.psi.LanguageLevel -import com.jetbrains.python.sdk.* -import com.jetbrains.python.sdk.add.v2.conda.detectCondaEnvironments -import com.jetbrains.python.sdk.add.v2.conda.detectCondaExecutable -import com.jetbrains.python.sdk.add.v2.hatch.HatchUIError -import com.jetbrains.python.sdk.flavors.conda.PyCondaEnv -import com.jetbrains.python.sdk.pipenv.getPipEnvExecutable -import com.jetbrains.python.sdk.poetry.getPoetryExecutable -import com.jetbrains.python.sdk.uv.impl.getUvExecutable +import com.jetbrains.python.sdk.PySdkToInstall +import com.jetbrains.python.sdk.PySdkUtil +import com.jetbrains.python.sdk.add.v2.conda.CondaState +import com.jetbrains.python.sdk.add.v2.hatch.HatchState +import com.jetbrains.python.sdk.add.v2.poetry.PoetryState +import com.jetbrains.python.sdk.add.v2.uv.UvState +import com.jetbrains.python.sdk.add.v2.venv.VenvState +import com.jetbrains.python.sdk.basePath +import com.jetbrains.python.sdk.isSystemWide import com.jetbrains.python.target.ui.TargetPanelExtension import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import org.jetbrains.annotations.ApiStatus import org.jetbrains.annotations.TestOnly import java.nio.file.Path -import kotlin.io.path.isDirectory import kotlin.io.path.pathString +interface ToolState { + fun initialize(scope: CoroutineScope) +} + @OptIn(ExperimentalCoroutinesApi::class) abstract class PythonAddInterpreterModel

( val projectPathFlows: ProjectPathFlows, @@ -57,18 +56,21 @@ abstract class PythonAddInterpreterModel

( val navigator: PythonNewEnvironmentDialogNavigator = PythonNewEnvironmentDialogNavigator() open val state: AddInterpreterState

= AddInterpreterState(propertyGraph) + val condaState: CondaState

= CondaState(fileSystem, propertyGraph) + val uvState: UvState

= UvState(fileSystem, propertyGraph) + val pipenvState: PipenvState

= PipenvState(fileSystem, propertyGraph) + val poetryState: PoetryState

= PoetryState(fileSystem, propertyGraph) + val hatchState: HatchState

= HatchState(fileSystem, propertyGraph, projectPathFlows) + val venvState: VenvState

= VenvState(fileSystem, propertyGraph, projectPathFlows) + internal val knownInterpreters: MutableStateFlow>?> = MutableStateFlow(null) private val _detectedInterpreters: MutableStateFlow>?> = MutableStateFlow(null) val detectedInterpreters: StateFlow>?> = _detectedInterpreters val manuallyAddedInterpreters: MutableStateFlow>> = MutableStateFlow(emptyList()) private var installable: List> = emptyList() - val condaEnvironments: MutableStateFlow> = MutableStateFlow(emptyList()) - val hatchEnvironmentsResult: MutableStateFlow>?> = MutableStateFlow(null) - lateinit var allInterpreters: StateFlow>?> lateinit var baseInterpreters: StateFlow>?> - val condaEnvironmentsLoading: MutableStateFlow = MutableStateFlow(true) @TestOnly @ApiStatus.Internal @@ -80,18 +82,20 @@ abstract class PythonAddInterpreterModel

( // 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. open fun initialize(scope: CoroutineScope) { + listOf(condaState, uvState, pipenvState, poetryState, hatchState, venvState).forEach { it.initialize(scope) } + merge( projectPathFlows.projectPathWithDefault, knownInterpreters, detectedInterpreters, manuallyAddedInterpreters, - condaEnvironments, - hatchEnvironmentsResult, + condaState.condaEnvironmentsResult, + hatchState.hatchEnvironmentsResult, ).map { modificationCounter.updateAndGet { it + 1 } - }.launchIn(scope + Dispatchers.EDT) + }.launchIn(scope + Dispatchers.UI) - scope.launch(TraceContext(message("tracecontext.loading.interpreter.list"), scope) + Dispatchers.EDT) { + scope.launch(TraceContext(message("tracecontext.loading.interpreter.list"), scope) + Dispatchers.UI) { installable = fileSystem.getInstallableInterpreters() val projectPathPrefix = projectPathFlows.projectPathWithDefault.first() val existingSelectableInterpreters = fileSystem.getExistingSelectableInterpreters(projectPathPrefix) @@ -119,29 +123,6 @@ abstract class PythonAddInterpreterModel

( val nonExistingInstallable = installable.filter { it.languageLevel !in existingLanguageLevels } manual + base.sorted() + nonExistingInstallable }.stateIn(scope, started = SharingStarted.Eagerly, initialValue = null) - - - scope.launch(TraceContext(message("tracecontext.detecting.conda.executable.and.environments"), scope) + Dispatchers.IO) { - detectCondaExecutable() - detectCondaEnvironments() - }.invokeOnCompletion { - this.condaEnvironmentsLoading.value = false - } - } - - suspend fun detectHatchEnvironments(hatchExecutable: BinaryToExec): PyResult> = withContext(Dispatchers.IO) { - val projectPath = projectPathFlows.projectPathWithDefault.first() - val hatchExecutablePath = (hatchExecutable as? BinOnEel)?.path - ?: return@withContext Result.failure(HatchUIError.HatchExecutablePathIsNotValid(hatchExecutable.toString())) - val hatchWorkingDirectory = if (projectPath.isDirectory()) projectPath else projectPath.parent - val hatchService = hatchWorkingDirectory.getHatchService(hatchExecutablePath).getOr { return@withContext it } - - val hatchEnvironments = hatchService.findVirtualEnvironments().getOr { return@withContext it } - val availableEnvironments = when { - hatchWorkingDirectory == projectPath -> hatchEnvironments - else -> HatchVirtualEnvironment.AVAILABLE_ENVIRONMENTS_FOR_NEW_PROJECT - } - success(availableEnvironments) } @@ -178,79 +159,6 @@ abstract class PythonAddInterpreterModel

( abstract class PythonMutableTargetAddInterpreterModel

(projectPathFlows: ProjectPathFlows, fileSystem: FileSystem

) : PythonAddInterpreterModel

(projectPathFlows, fileSystem) { override val state: MutableTargetState

= MutableTargetState(propertyGraph) - - override fun initialize(scope: CoroutineScope) { - super.initialize(scope) - scope.launch(TraceContext(message("tracecontext.detecting.poetry.executable"), scope)) { - detectPoetryExecutable() - } - scope.launch(TraceContext(message("tracecontext.detecting.pip.executable"), scope)) { - detectPipEnvExecutable() - } - scope.launch(TraceContext(message("tracecontext.detecting.uv.executable"), scope)) { - detectUvExecutable() - } - scope.launch(TraceContext(message("tracecontext.detecting.hatch.executable"), scope)) { - detectHatchExecutable() - } - } - - suspend fun detectPoetryExecutable() { - if ((fileSystem as? FileSystem.Eel)?.eelApi != localEel) return // getPoetryExecutable() works only with localEel currently - - state.poetryExecutable.autodetectWithVersionProbe("poetry") { - getPoetryExecutable().getOrNull()?.let { PathHolder.Eel(it) } as P? - } - } - - suspend fun detectPipEnvExecutable() { - if ((fileSystem as? FileSystem.Eel)?.eelApi != localEel) return // getPipEnvExecutable() works only with localEel currently - - state.pipenvExecutable.autodetectWithVersionProbe("pipenv") { - getPipEnvExecutable().getOrNull()?.let { PathHolder.Eel(it) } as P? - } - } - - suspend fun detectUvExecutable() { - if ((fileSystem as? FileSystem.Eel)?.eelApi != localEel) return // getUvExecutable() works only with localEel currently - - state.uvExecutable.autodetectWithVersionProbe("uv") { - getUvExecutable()?.let { PathHolder.Eel(it) } as P? - } - } - - suspend fun detectHatchExecutable() { - if (fileSystem !is FileSystem.Eel) return // getOrDetectHatchExecutablePath() works only with eel filesystem currently - - state.hatchExecutable.autodetectWithVersionProbe("hatch") { - getOrDetectHatchExecutablePath(fileSystem.eelApi).getOrNull()?.let { PathHolder.Eel(it) } as P? - } - } - - private suspend fun ObservableMutableProperty?>.autodetectWithVersionProbe( - toolVersionPrefix: String, - toolPathSupplier: suspend () -> P?, - ): Unit = withContext(Dispatchers.IO) { - val path = toolPathSupplier.invoke() - val validatedPath = path?.validateToolExecutableByVersionProbe(toolVersionPrefix) - ?: ValidatedPath.Executable( - pathHolder = path, - validationResult = PyResult.localizedError(message("python.sdk.executable.is.not.detected")) - ) - - withContext(Dispatchers.EDT) { - set(validatedPath) - } - } - - private suspend fun P.validateToolExecutableByVersionProbe(toolVersionPrefix: String): ValidatedPath.Executable

{ - val binaryToExec = fileSystem.getBinaryToExec(this) - val validationResult = binaryToExec.getToolVersion(toolVersionPrefix) - return ValidatedPath.Executable( - pathHolder = this, - validationResult = validationResult - ) - } } class PythonLocalAddInterpreterModel

(projectPathFlows: ProjectPathFlows, fileSystem: FileSystem

) : PythonMutableTargetAddInterpreterModel

(projectPathFlows, fileSystem) { @@ -357,58 +265,17 @@ sealed interface ValidatedPath { open class AddInterpreterState

(propertyGraph: PropertyGraph) { val selectedInterpreter: ObservableMutableProperty?> = propertyGraph.property(null) - val condaExecutable: ObservableMutableProperty?> = propertyGraph.property(null) - - /** - * Use [PythonAddInterpreterModel.getBaseCondaOrError] - */ - val selectedCondaEnv: ObservableMutableProperty = propertyGraph.property(null) - - /** - * Use [PythonAddInterpreterModel.getBaseCondaOrError] - */ - val baseCondaEnv: ObservableMutableProperty = propertyGraph.property(null) - - val selectedHatchEnv: ObservableMutableProperty = propertyGraph.property(null) - val targetPanelExtension: ObservableMutableProperty = propertyGraph.property(null) } class MutableTargetState

(propertyGraph: PropertyGraph) : AddInterpreterState

(propertyGraph) { val baseInterpreter: ObservableMutableProperty?> = propertyGraph.property(null) - val newCondaEnvName: ObservableMutableProperty = propertyGraph.property("") - val poetryExecutable: ObservableMutableProperty?> = propertyGraph.property(null) - val uvExecutable: ObservableMutableProperty?> = propertyGraph.property(null) - val hatchExecutable: ObservableMutableProperty?> = propertyGraph.property(null) - val pipenvExecutable: ObservableMutableProperty?> = propertyGraph.property(null) - val venvPath: ObservableMutableProperty?> = propertyGraph.property(null) - val inheritSitePackages: GraphProperty = propertyGraph.property(false) - - /** - * Associate SDK with particular module (if true) - */ - val makeAvailableForAllProjects: GraphProperty = propertyGraph.property(false) } internal val

PythonAddInterpreterModel

.existingSdks: List get() = allInterpreters.value?.filterIsInstance>()?.map { it.sdkWrapper.sdk } ?: emptyList() - -internal suspend fun PythonAddInterpreterModel<*>.detectCondaEnvironmentsOrError(errorSink: ErrorSink) { - detectCondaEnvironments().onFailure { - errorSink.emit(it) - } -} - -internal suspend fun PythonAddInterpreterModel<*>.getBaseCondaOrError(): PyResult { - var baseConda = state.baseCondaEnv.get() - if (baseConda != null) return PyResult.success(baseConda) - detectCondaEnvironments().getOr { return it } - baseConda = state.baseCondaEnv.get() - return if (baseConda != null) PyResult.success(baseConda) else PyResult.localizedError(message("python.sdk.conda.no.base.env.error")) -} - internal suspend fun PythonAddInterpreterModel<*>.getBasePath(module: Module?): Path = withContext(Dispatchers.IO) { val pyProjectTomlBased = module?.let { PyProjectToml.findFile(it)?.toNioPathOrNull()?.parent } diff --git a/python/src/com/jetbrains/python/sdk/add/v2/poetry/EnvironmentCreatorPoetry.kt b/python/src/com/jetbrains/python/sdk/add/v2/poetry/EnvironmentCreatorPoetry.kt index 692823a83f97..7ffeacf1ab84 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/poetry/EnvironmentCreatorPoetry.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/poetry/EnvironmentCreatorPoetry.kt @@ -5,7 +5,6 @@ import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.application.EDT import com.intellij.openapi.components.* import com.intellij.openapi.module.Module -import com.intellij.openapi.observable.properties.ObservableMutableProperty import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.ui.validation.DialogValidationRequestor import com.intellij.openapi.vfs.VirtualFileManager @@ -46,7 +45,7 @@ internal class EnvironmentCreatorPoetry( errorSink: ErrorSink, ) : CustomNewEnvironmentCreator

("poetry", model, errorSink) { override val interpreterType: InterpreterType = InterpreterType.POETRY - override val executable: ObservableMutableProperty?> = model.state.poetryExecutable + override val toolValidator: ToolValidator

= model.poetryState.toolValidator override val installationVersion: String = "1.8.0" private val isInProjectEnvFlow = MutableStateFlow(service().state.isInProjectEnv) @@ -104,7 +103,7 @@ internal class EnvironmentCreatorPoetry( if ((model.fileSystem as? FileSystem.Eel)?.eelApi !is LocalEelApi) return val savingPath = (pathHolder as? PathHolder.Eel)?.path - ?: (executable.get()?.pathHolder as? PathHolder.Eel)?.path + ?: (toolValidator.backProperty.get()?.pathHolder as? PathHolder.Eel)?.path savingPath?.let { PropertiesComponent.getInstance().poetryPath = it.toString() @@ -119,10 +118,6 @@ internal class EnvironmentCreatorPoetry( } } - override suspend fun detectExecutable() { - model.detectPoetryExecutable() - } - override fun onVenvSelectExisting() { PythonNewProjectWizardCollector.logExistingVenvFixUsed() diff --git a/python/src/com/jetbrains/python/sdk/add/v2/poetry/PoetryExistingEnvironmentSelector.kt b/python/src/com/jetbrains/python/sdk/add/v2/poetry/PoetryExistingEnvironmentSelector.kt index 5c81e50fb983..cd0d2ea842a5 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/poetry/PoetryExistingEnvironmentSelector.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/poetry/PoetryExistingEnvironmentSelector.kt @@ -2,7 +2,6 @@ package com.jetbrains.python.sdk.add.v2.poetry import com.intellij.openapi.module.Module -import com.intellij.openapi.observable.properties.ObservableMutableProperty import com.intellij.openapi.projectRoots.ProjectJdkTable import com.intellij.openapi.projectRoots.Sdk import com.jetbrains.python.PyBundle @@ -14,7 +13,9 @@ import com.jetbrains.python.sdk.add.v2.CustomExistingEnvironmentSelector import com.jetbrains.python.sdk.add.v2.DetectedSelectableInterpreter import com.jetbrains.python.sdk.add.v2.PathHolder import com.jetbrains.python.sdk.add.v2.PythonMutableTargetAddInterpreterModel +import com.jetbrains.python.sdk.add.v2.PathValidator import com.jetbrains.python.sdk.add.v2.ValidatedPath +import com.jetbrains.python.sdk.add.v2.Version import com.jetbrains.python.sdk.basePath import com.jetbrains.python.sdk.poetry.createPoetrySdk import com.jetbrains.python.sdk.poetry.detectPoetryEnvs @@ -25,7 +26,7 @@ import java.nio.file.Path import kotlin.io.path.pathString internal class PoetryExistingEnvironmentSelector(model: PythonMutableTargetAddInterpreterModel

, module: Module?) : CustomExistingEnvironmentSelector

("poetry", model, module) { - override val executable: ObservableMutableProperty?> = model.state.poetryExecutable + override val toolState: PathValidator> = model.poetryState.toolValidator override val interpreterType: InterpreterType = InterpreterType.POETRY override suspend fun getOrCreateSdk(moduleOrProject: ModuleOrProject): PyResult { diff --git a/python/src/com/jetbrains/python/sdk/add/v2/poetry/PoetryState.kt b/python/src/com/jetbrains/python/sdk/add/v2/poetry/PoetryState.kt new file mode 100644 index 000000000000..9a6c87b8d863 --- /dev/null +++ b/python/src/com/jetbrains/python/sdk/add/v2/poetry/PoetryState.kt @@ -0,0 +1,37 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.sdk.add.v2.poetry + +import com.intellij.openapi.observable.properties.ObservableMutableProperty +import com.intellij.openapi.observable.properties.PropertyGraph +import com.intellij.platform.eel.provider.localEel +import com.jetbrains.python.getOrNull +import com.jetbrains.python.sdk.add.v2.* +import com.jetbrains.python.sdk.poetry.getPoetryExecutable +import kotlinx.coroutines.CoroutineScope + +class PoetryState

( + fileSystem: FileSystem

, + propertyGraph: PropertyGraph, +) : ToolState { + val poetryExecutable: ObservableMutableProperty?> = propertyGraph.property(null) + + val toolValidator: ToolValidator

= ToolValidator( + fileSystem = fileSystem, + toolVersionPrefix = "poetry", + backProperty = poetryExecutable, + propertyGraph = propertyGraph, + defaultPathSupplier = { + when (fileSystem) { + is FileSystem.Eel -> { + if (fileSystem.eelApi == localEel) getPoetryExecutable().getOrNull()?.let { PathHolder.Eel(it) } as P? + else null // getPoetryExecutable() works only with localEel currently + } + else -> null + } + } + ) + + override fun initialize(scope: CoroutineScope) { + toolValidator.initialize(scope) + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/sdk/add/v2/uiUtils.kt b/python/src/com/jetbrains/python/sdk/add/v2/uiUtils.kt index 289761bc4a22..1c3bcafcc427 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/uiUtils.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/uiUtils.kt @@ -37,7 +37,6 @@ import com.jetbrains.python.sdk.add.v2.PythonInterpreterSelectionMethod.CREATE_N import com.jetbrains.python.sdk.add.v2.PythonInterpreterSelectionMethod.SELECT_EXISTING import com.jetbrains.python.sdk.add.v2.PythonInterpreterSelectionMode.CUSTOM import com.jetbrains.python.sdk.add.v2.PythonSupportedEnvironmentManagers.VIRTUALENV -import com.jetbrains.python.sdk.add.v2.conda.detectCondaExecutable import com.jetbrains.python.sdk.flavors.PythonSdkFlavor import com.jetbrains.python.sdk.flavors.conda.PyCondaEnv import com.jetbrains.python.sdk.flavors.conda.PyCondaEnvIdentity @@ -425,10 +424,7 @@ internal fun

createInstallCondaFix(model: PythonAddInterpreterM PythonSdkFlavor.clearExecutablesCache() CondaInstallManager.installLatest(null) runWithModalProgressBlocking(ModalTaskOwner.guess(), message("sdk.create.custom.venv.progress.title.detect.executable")) { - model.condaEnvironmentsLoading.value = true - model.detectCondaExecutable() - model.detectCondaEnvironmentsOrError(errorSink) - model.condaEnvironmentsLoading.value = false + model.condaState.toolValidator.autodetectExecutable() } } } diff --git a/python/src/com/jetbrains/python/sdk/add/v2/uv/EnvironmentCreatorUv.kt b/python/src/com/jetbrains/python/sdk/add/v2/uv/EnvironmentCreatorUv.kt index 6e4993f831be..54a030e8d30a 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/uv/EnvironmentCreatorUv.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/uv/EnvironmentCreatorUv.kt @@ -62,15 +62,15 @@ internal class EnvironmentCreatorUv

( errorSink: ErrorSink, ) : CustomNewEnvironmentCreator

("uv", model, errorSink) { override val interpreterType: InterpreterType = InterpreterType.UV - override val executable: ObservableMutableProperty?> = model.state.uvExecutable - private val executableFlow = MutableStateFlow(model.state.uvExecutable.get()) + override val toolValidator: ToolValidator

= model.uvState.toolValidator + private val executableFlow = MutableStateFlow(model.uvState.uvExecutable.get()) private val pythonVersion: ObservableMutableProperty = propertyGraph.property(null) private lateinit var versionComboBox: ComboBox private val loading = AtomicBooleanProperty(false) init { - executable.afterChange { + toolValidator.backProperty.afterChange { executableFlow.value = it } } @@ -91,18 +91,14 @@ internal class EnvironmentCreatorUv

( .visibleIf(loading) } - executablePath = validatableExecutableField( - propertyGraph = propertyGraph, + executablePath = validatablePathField( fileSystem = model.fileSystem, - backProperty = executable, + pathValidator = toolValidator, validationRequestor = validationRequestor, labelText = message("sdk.create.custom.venv.executable.path", "uv"), missingExecutableText = message("sdk.create.custom.venv.missing.text", "uv"), installAction = createInstallFix(errorSink) - ) { - val result = model.fileSystem.getBinaryToExec(it).getToolVersion("uv") - ValidatedPath.Executable(it, result) - } + ) row("") { venvExistenceValidationAlert(validationRequestor) { @@ -184,7 +180,7 @@ internal class EnvironmentCreatorUv

( if ((model.fileSystem as? FileSystem.Eel)?.eelApi !is LocalEelApi) return val savingPath = (pathHolder as? PathHolder.Eel)?.path - ?: (executable.get()?.pathHolder as? PathHolder.Eel)?.path + ?: (toolValidator.backProperty.get()?.pathHolder as? PathHolder.Eel)?.path savingPath?.let { setUvExecutable(it) } @@ -198,9 +194,4 @@ internal class EnvironmentCreatorUv

( ): PyResult { return setupNewUvSdkAndEnv(moduleBasePath, baseSdks, pythonVersion.get()) } - - override suspend fun detectExecutable() { - model.detectUvExecutable() - } - } \ No newline at end of file diff --git a/python/src/com/jetbrains/python/sdk/add/v2/uv/UvExistingEnvironmentSelector.kt b/python/src/com/jetbrains/python/sdk/add/v2/uv/UvExistingEnvironmentSelector.kt index c0d019fed855..bb481908c256 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/uv/UvExistingEnvironmentSelector.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/uv/UvExistingEnvironmentSelector.kt @@ -2,7 +2,6 @@ package com.jetbrains.python.sdk.add.v2.uv import com.intellij.openapi.module.Module -import com.intellij.openapi.observable.properties.ObservableMutableProperty import com.intellij.openapi.projectRoots.Sdk import com.jetbrains.python.PyBundle import com.jetbrains.python.Result @@ -13,7 +12,9 @@ import com.jetbrains.python.sdk.add.v2.CustomExistingEnvironmentSelector import com.jetbrains.python.sdk.add.v2.DetectedSelectableInterpreter import com.jetbrains.python.sdk.add.v2.PathHolder import com.jetbrains.python.sdk.add.v2.PythonMutableTargetAddInterpreterModel +import com.jetbrains.python.sdk.add.v2.PathValidator import com.jetbrains.python.sdk.add.v2.ValidatedPath +import com.jetbrains.python.sdk.add.v2.Version import com.jetbrains.python.sdk.associatedModulePath import com.jetbrains.python.sdk.basePath import com.jetbrains.python.sdk.isAssociatedWithModule @@ -28,7 +29,7 @@ import kotlin.io.path.pathString internal class UvExistingEnvironmentSelector(model: PythonMutableTargetAddInterpreterModel

, module: Module?) : CustomExistingEnvironmentSelector

("uv", model, module) { - override val executable: ObservableMutableProperty?> = model.state.uvExecutable + override val toolState: PathValidator> = model.uvState.toolValidator override val interpreterType: InterpreterType = InterpreterType.UV override suspend fun getOrCreateSdk(moduleOrProject: ModuleOrProject): PyResult { diff --git a/python/src/com/jetbrains/python/sdk/add/v2/uv/UvInterpreterSection.kt b/python/src/com/jetbrains/python/sdk/add/v2/uv/UvInterpreterSection.kt index ceaeaafc627f..6401ead38cfd 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/uv/UvInterpreterSection.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/uv/UvInterpreterSection.kt @@ -38,10 +38,10 @@ internal class UvInterpreterSection( uvCreator.onShown(scope) } - fun hintVisiblePredicate() = _uv and model.state.uvExecutable.isNotNull() + fun hintVisiblePredicate() = _uv and model.uvState.uvExecutable.isNotNull() private fun selectUvIfExists() { - if (model.state.uvExecutable.get() != null + if (model.uvState.uvExecutable.get() != null && selectedMode.get() != PythonInterpreterSelectionMode.PROJECT_UV) { selectedMode.set(PythonInterpreterSelectionMode.PROJECT_UV) } diff --git a/python/src/com/jetbrains/python/sdk/add/v2/uv/UvState.kt b/python/src/com/jetbrains/python/sdk/add/v2/uv/UvState.kt new file mode 100644 index 000000000000..48799ff4ef22 --- /dev/null +++ b/python/src/com/jetbrains/python/sdk/add/v2/uv/UvState.kt @@ -0,0 +1,36 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.sdk.add.v2.uv + +import com.intellij.openapi.observable.properties.ObservableMutableProperty +import com.intellij.openapi.observable.properties.PropertyGraph +import com.intellij.platform.eel.provider.localEel +import com.jetbrains.python.sdk.add.v2.* +import com.jetbrains.python.sdk.uv.impl.getUvExecutable +import kotlinx.coroutines.CoroutineScope + +class UvState

( + fileSystem: FileSystem

, + propertyGraph: PropertyGraph, +) : ToolState { + val uvExecutable: ObservableMutableProperty?> = propertyGraph.property(null) + + val toolValidator: ToolValidator

= ToolValidator( + fileSystem = fileSystem, + toolVersionPrefix = "uv", + backProperty = uvExecutable, + propertyGraph = propertyGraph, + defaultPathSupplier = { + when (fileSystem) { + is FileSystem.Eel -> { + if (fileSystem.eelApi == localEel) getUvExecutable()?.let { PathHolder.Eel(it) } as P? + else null // getUvExecutable() works only with localEel currently + } + else -> null + } + } + ) + + override fun initialize(scope: CoroutineScope) { + toolValidator.initialize(scope) + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/sdk/add/v2/venv/EnvironmentCreatorVenv.kt b/python/src/com/jetbrains/python/sdk/add/v2/venv/EnvironmentCreatorVenv.kt index b3303a08ec48..f7986719d2c1 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/venv/EnvironmentCreatorVenv.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/venv/EnvironmentCreatorVenv.kt @@ -2,8 +2,8 @@ package com.jetbrains.python.sdk.add.v2.venv import com.intellij.openapi.application.EDT +import com.intellij.openapi.observable.properties.ObservableMutableProperty import com.intellij.openapi.observable.util.isNotNull -import com.intellij.openapi.observable.util.transform import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.ui.validation.DialogValidationRequestor import com.intellij.ui.components.ActionLink @@ -14,19 +14,7 @@ import com.jetbrains.python.errorProcessing.PyResult import com.jetbrains.python.newProject.collector.InterpreterStatisticsInfo import com.jetbrains.python.newProjectWizard.collector.PythonNewProjectWizardCollector import com.jetbrains.python.sdk.ModuleOrProject -import com.jetbrains.python.sdk.add.v2.PathHolder -import com.jetbrains.python.sdk.add.v2.PythonInterpreterComboBox -import com.jetbrains.python.sdk.add.v2.PythonInterpreterCreationTargets -import com.jetbrains.python.sdk.add.v2.PythonInterpreterSelectionMethod -import com.jetbrains.python.sdk.add.v2.PythonMutableTargetAddInterpreterModel -import com.jetbrains.python.sdk.add.v2.PythonNewEnvironmentCreator -import com.jetbrains.python.sdk.add.v2.PythonSupportedEnvironmentManagers -import com.jetbrains.python.sdk.add.v2.ValidatedPath -import com.jetbrains.python.sdk.add.v2.ValidatedPathField -import com.jetbrains.python.sdk.add.v2.VenvAlreadyExistsError -import com.jetbrains.python.sdk.add.v2.pythonInterpreterComboBox -import com.jetbrains.python.sdk.add.v2.toStatisticsField -import com.jetbrains.python.sdk.add.v2.validatableVenvField +import com.jetbrains.python.sdk.add.v2.* import com.jetbrains.python.statistics.InterpreterCreationMode import com.jetbrains.python.statistics.InterpreterType import kotlinx.coroutines.CoroutineScope @@ -39,14 +27,25 @@ class EnvironmentCreatorVenv

(model: PythonMutableTargetAddInterp private lateinit var versionComboBox: PythonInterpreterComboBox

private lateinit var venvPathField: ValidatedPathField> - private val locationValidationMessage = propertyGraph.property("Current location already exists") + private val venvAlreadyExistsError = propertyGraph.property?>(null) + private val venvAlreadyExistsErrorMessage: ObservableMutableProperty = propertyGraph.property("") + private var locationModified = false + init { + propertyGraph.dependsOn(venvAlreadyExistsError, model.venvState.backProperty, deleteWhenChildModified = false) { + model.venvState.backProperty.get()?.validationResult?.errorOrNull as? VenvAlreadyExistsError

+ } + propertyGraph.dependsOn(venvAlreadyExistsErrorMessage, venvAlreadyExistsError, deleteWhenChildModified = false) { + venvAlreadyExistsError.get()?.message ?: "" + } + } + override fun setupUI(panel: Panel, validationRequestor: DialogValidationRequestor) { val secondFixLink = ActionLink(message("sdk.create.custom.venv.select.existing.link")) { PythonNewProjectWizardCollector.logExistingVenvFixUsed() - val venvAlreadyExistsError = model.state.venvPath.get()?.validationResult?.errorOrNull as? VenvAlreadyExistsError

+ val venvAlreadyExistsError = venvAlreadyExistsError.get() venvAlreadyExistsError?.let { error -> val interpreter = error.detectedSelectableInterpreter.also { @@ -71,29 +70,28 @@ class EnvironmentCreatorVenv

(model: PythonMutableTargetAddInterp ) - venvPathField = validatableVenvField( - propertyGraph = propertyGraph, + venvPathField = validatablePathField( fileSystem = model.fileSystem, - backProperty = model.state.venvPath, + pathValidator = model.venvState.venvValidator, validationRequestor = validationRequestor, labelText = message("sdk.create.custom.location"), missingExecutableText = null, - selectedPathValidator = { model.fileSystem.validateVenv(it) } + isFileSelectionMode = false, ) row("") { - validationTooltip(locationValidationMessage, secondFixLink) + validationTooltip(venvAlreadyExistsErrorMessage, secondFixLink) .align(Align.FILL) - }.visibleIf(venvPathField.backProperty.transform { it?.validationResult?.errorOrNull }.isNotNull()) + }.visibleIf(venvAlreadyExistsError.isNotNull()) row("") { checkBox(message("sdk.create.custom.inherit.packages")) - .bindSelected(model.state.inheritSitePackages) + .bindSelected(model.venvState.inheritSitePackages) } row("") { checkBox(message("available.to.all.projects")) - .bindSelected(model.state.makeAvailableForAllProjects) + .bindSelected(model.venvState.makeAvailableForAllProjects) } } } @@ -106,13 +104,12 @@ class EnvironmentCreatorVenv

(model: PythonMutableTargetAddInterp model.projectPathFlows.projectPathWithDefault.onEach { if (locationModified) return@onEach - val suggestedVirtualEnv = model.fileSystem.suggestVenv(it) - model.state.venvPath.set(suggestedVirtualEnv) + model.venvState.venvValidator.autodetectFolder() }.launchIn(scope + Dispatchers.EDT) } override suspend fun getOrCreateSdk(moduleOrProject: ModuleOrProject): PyResult { - val venv = model.state.venvPath.get()?.pathHolder + val venv = model.venvState.backProperty.get()?.pathHolder ?: return PyResult.localizedError(message("no.venv.path.specified")) return model.setupVirtualenv(venv, moduleOrProject) } @@ -121,8 +118,8 @@ class EnvironmentCreatorVenv

(model: PythonMutableTargetAddInterp return InterpreterStatisticsInfo( type = InterpreterType.VIRTUALENV, target = target.toStatisticsField(), - globalSitePackage = model.state.inheritSitePackages.get(), - makeAvailableToAllProjects = model.state.makeAvailableForAllProjects.get(), + globalSitePackage = model.venvState.inheritSitePackages.get(), + makeAvailableToAllProjects = model.venvState.makeAvailableForAllProjects.get(), previouslyConfigured = false, isWSLContext = false, // todo fix for wsl creationMode = InterpreterCreationMode.CUSTOM diff --git a/python/src/com/jetbrains/python/sdk/add/v2/venv/VenvState.kt b/python/src/com/jetbrains/python/sdk/add/v2/venv/VenvState.kt new file mode 100644 index 000000000000..04bb35d37afb --- /dev/null +++ b/python/src/com/jetbrains/python/sdk/add/v2/venv/VenvState.kt @@ -0,0 +1,34 @@ +package com.jetbrains.python.sdk.add.v2.venv + +import com.intellij.openapi.observable.properties.GraphProperty +import com.intellij.openapi.observable.properties.ObservableMutableProperty +import com.intellij.openapi.observable.properties.PropertyGraph +import com.jetbrains.python.newProjectWizard.projectPath.ProjectPathFlows +import com.jetbrains.python.sdk.add.v2.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first + +class VenvState

( + fileSystem: FileSystem

, + propertyGraph: PropertyGraph, + projectPathFlows: ProjectPathFlows, +) : ToolState { + val backProperty: ObservableMutableProperty?> = propertyGraph.property(null) + val inheritSitePackages: GraphProperty = propertyGraph.property(false) + val makeAvailableForAllProjects: GraphProperty = propertyGraph.property(false) + + val venvValidator: FolderValidator

= FolderValidator( + fileSystem = fileSystem, + backProperty = backProperty, + propertyGraph = propertyGraph, + defaultPathSupplier = { + val projectPath = projectPathFlows.projectPathWithDefault.first() + fileSystem.suggestVenv(projectPath) + }, + pathValidator = fileSystem::validateVenv + ) + + override fun initialize(scope: CoroutineScope) { + venvValidator.initialize(scope) + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/sdk/add/v2/venv/helper.kt b/python/src/com/jetbrains/python/sdk/add/v2/venv/helper.kt index 42fd0c9cf097..8b40c1939afa 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/venv/helper.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/venv/helper.kt @@ -25,7 +25,7 @@ suspend fun

PythonMutableTargetAddInterpreterModel

.setupVirt project = moduleOrProject?.project, pathToBasePython = baseSdkPath, pathToVenvHome = venvFolder, - inheritSitePackages = state.inheritSitePackages.get(), + inheritSitePackages = venvState.inheritSitePackages.get(), existingSdks = existingSdks ).getOr { return it } @@ -34,7 +34,7 @@ suspend fun

PythonMutableTargetAddInterpreterModel

.setupVirt if (module != null) { module.excludeInnerVirtualEnv(newSdk.sdk) - if (!this.state.makeAvailableForAllProjects.get()) { + if (!this.venvState.makeAvailableForAllProjects.get()) { newSdk.sdk.setAssociationToModule(module) } }