PY-78001 Support adding existing venv's as UV

Add UvExistingEnvironmentSelector

(cherry picked from commit be8827506d521c5487cf4fbb3ca15d979f760d44)

GitOrigin-RevId: 5a8cdf35bbd89e0473724554d3390d6d9eb19311
This commit is contained in:
Egor.Eliseev
2025-01-10 15:54:01 +01:00
committed by intellij-monorepo-bot
parent 1c33aab97d
commit dca31999f3
13 changed files with 161 additions and 55 deletions

View File

@@ -11,13 +11,10 @@ import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil
import com.intellij.pycharm.community.ide.impl.PyCharmCommunityCustomizationBundle
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.jetbrains.python.sdk.*
import com.jetbrains.python.sdk.basePath
import com.jetbrains.python.sdk.configuration.PyProjectSdkConfigurationExtension
import com.jetbrains.python.sdk.uv.PY_PROJECT_TOML
import com.jetbrains.python.sdk.uv.impl.getUvExecutable
import com.jetbrains.python.sdk.uv.setupUvSdkUnderProgress
import java.io.FileNotFoundException
import java.nio.file.Path
class PyUvSdkConfiguration : PyProjectSdkConfigurationExtension {
companion object {
@@ -54,12 +51,7 @@ class PyUvSdkConfiguration : PyProjectSdkConfigurationExtension {
override fun supportsHeadlessModel(): Boolean = true
private suspend fun createUv(module: Module): Result<Sdk> {
val basePath = module.basePath?.let { Path.of(it) }
if (basePath == null) {
return Result.failure(FileNotFoundException("Can't find module base path"))
}
val sdk = setupUvSdkUnderProgress(module, basePath, ProjectJdkTable.getInstance().allJdks.toList(), null)
val sdk = setupUvSdkUnderProgress(ModuleOrProject.ModuleAndProject(module), ProjectJdkTable.getInstance().allJdks.toList(), null)
sdk.onSuccess {
SdkConfigurationUtil.addSdk(it)
}

View File

@@ -1,6 +1,7 @@
// Copyright 2000-2024 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.module.Module
import com.intellij.openapi.observable.properties.ObservableMutableProperty
import com.intellij.openapi.observable.util.notEqualsTo
import com.intellij.openapi.ui.validation.DialogValidationRequestor
@@ -11,7 +12,6 @@ import com.jetbrains.python.newProject.collector.InterpreterStatisticsInfo
import com.jetbrains.python.sdk.ModuleOrProject
import com.jetbrains.python.sdk.PySdkUtil
import com.jetbrains.python.sdk.PythonSdkUtil
import com.jetbrains.python.sdk.poetry.pyProjectToml
import com.jetbrains.python.statistics.InterpreterCreationMode
import com.jetbrains.python.statistics.InterpreterType
import kotlinx.coroutines.flow.MutableStateFlow
@@ -31,7 +31,7 @@ abstract class CustomExistingEnvironmentSelector(private val name: String, model
model.scope.launch {
val modulePath = when (moduleOrProject) {
is ModuleOrProject.ProjectOnly -> moduleOrProject.project.basePath?.let { Path.of(it) }
is ModuleOrProject.ModuleAndProject -> pyProjectToml(moduleOrProject.module)?.let { Path.of(it.parent.path) }
is ModuleOrProject.ModuleAndProject -> findModulePath(moduleOrProject.module)
}
if (modulePath != null) {
@@ -49,6 +49,12 @@ abstract class CustomExistingEnvironmentSelector(private val name: String, model
message("sdk.create.custom.venv.missing.text", name),
).component
addInterpretersComboBox(panel)
}
}
protected open fun addInterpretersComboBox(panel: Panel) {
with(panel) {
row(message("sdk.create.custom.existing.env.title", name.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() })) {
comboBox = pythonInterpreterComboBox(selectedEnv, model, { path -> addEnvByPath(path) }, model.interpreterLoading)
.align(Align.FILL)
@@ -82,4 +88,5 @@ abstract class CustomExistingEnvironmentSelector(private val name: String, model
internal abstract val executable: ObservableMutableProperty<String>
internal abstract val interpreterType: InterpreterType
internal abstract suspend fun detectEnvironments(modulePath: Path)
internal abstract suspend fun findModulePath(module: Module): Path?
}

View File

@@ -33,7 +33,7 @@ class EnvironmentCreatorUv(model: PythonMutableTargetAddInterpreterModel, privat
}
val python = homePath?.let { Path.of(it) }
return setupUvSdkUnderProgress(module, Path.of(projectPath), baseSdks, python)
return setupUvSdkUnderProgress(ModuleOrProject.ModuleAndProject(module), baseSdks, python)
}
override suspend fun detectExecutable() {

View File

@@ -1,19 +1,22 @@
// Copyright 2000-2024 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.module.Module
import com.intellij.openapi.observable.properties.ObservableMutableProperty
import com.intellij.openapi.projectRoots.ProjectJdkTable
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.vfs.toNioPathOrNull
import com.jetbrains.python.sdk.ModuleOrProject
import com.jetbrains.python.sdk.poetry.detectPoetryEnvs
import com.jetbrains.python.sdk.poetry.isPoetry
import com.jetbrains.python.sdk.poetry.pyProjectToml
import com.jetbrains.python.sdk.poetry.setupPoetrySdkUnderProgress
import com.jetbrains.python.statistics.InterpreterType
import com.jetbrains.python.statistics.version
import java.nio.file.Path
import kotlin.io.path.pathString
class PoetryExistingEnvironmentSelector(model: PythonMutableTargetAddInterpreterModel, moduleOrProject: ModuleOrProject) : CustomExistingEnvironmentSelector("poetry", model, moduleOrProject) {
internal class PoetryExistingEnvironmentSelector(model: PythonMutableTargetAddInterpreterModel, moduleOrProject: ModuleOrProject) : CustomExistingEnvironmentSelector("poetry", model, moduleOrProject) {
override val executable: ObservableMutableProperty<String> = model.state.poetryExecutable
override val interpreterType: InterpreterType = InterpreterType.POETRY
@@ -37,4 +40,6 @@ class PoetryExistingEnvironmentSelector(model: PythonMutableTargetAddInterpreter
existingEnvironments.value = existingEnvs
}
override suspend fun findModulePath(module: Module): Path? = pyProjectToml(module)?.toNioPathOrNull()?.parent
}

View File

@@ -38,6 +38,7 @@ class PythonAddCustomInterpreter(val model: PythonMutableTargetAddInterpreterMod
put(PYTHON, PythonExistingEnvironmentSelector(model))
put(CONDA, CondaExistingEnvironmentSelector(model, errorSink))
if (moduleOrProject != null) put(POETRY, PoetryExistingEnvironmentSelector(model, moduleOrProject))
if (moduleOrProject != null) put(UV, UvExistingEnvironmentSelector(model, moduleOrProject))
}
val currentSdkManager: PythonAddEnvironment

View File

@@ -0,0 +1,59 @@
// 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.module.Module
import com.intellij.openapi.observable.properties.ObservableMutableProperty
import com.intellij.openapi.projectRoots.ProjectJdkTable
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.vfs.toNioPathOrNull
import com.jetbrains.python.sdk.ModuleOrProject
import com.jetbrains.python.sdk.associatedModulePath
import com.jetbrains.python.sdk.isAssociatedWithModule
import com.jetbrains.python.sdk.uv.isUv
import com.jetbrains.python.sdk.uv.pyProjectToml
import com.jetbrains.python.sdk.uv.setupUvSdkUnderProgress
import com.jetbrains.python.statistics.InterpreterType
import com.jetbrains.python.statistics.version
import java.io.FileNotFoundException
import java.nio.file.Path
import kotlin.io.path.pathString
internal class UvExistingEnvironmentSelector(model: PythonMutableTargetAddInterpreterModel, moduleOrProject: ModuleOrProject)
: CustomExistingEnvironmentSelector("uv", model, moduleOrProject) {
override val executable: ObservableMutableProperty<String> = model.state.uvExecutable
override val interpreterType: InterpreterType = InterpreterType.UV
override suspend fun getOrCreateSdk(moduleOrProject: ModuleOrProject): Result<Sdk> {
val selectedInterpreterPath = selectedEnv.get()?.homePath ?: return Result.failure(FileNotFoundException("No selected interpreter"))
val existingSdk = ProjectJdkTable.getInstance().allJdks.find { it.homePath == selectedInterpreterPath }
val associatedModule = extractModule(moduleOrProject)
// uv sdk in current module
if (existingSdk != null && existingSdk.isUv && existingSdk.isAssociatedWithModule(associatedModule)) {
return Result.success(existingSdk)
}
val existingWorkingDir = existingSdk?.associatedModulePath?.let { Path.of(it) }
val usePip = existingWorkingDir!= null && !existingSdk.isUv
return setupUvSdkUnderProgress(moduleOrProject, ProjectJdkTable.getInstance().allJdks.toList(), Path.of(selectedInterpreterPath), existingWorkingDir, usePip)
}
override suspend fun detectEnvironments(modulePath: Path) {
val existingEnvs = ProjectJdkTable.getInstance().allJdks.filter {
it.isUv && (it.associatedModulePath == modulePath.pathString || it.associatedModulePath == null)
}.mapNotNull { env ->
env.homePath?.let { path -> DetectedSelectableInterpreter(path, env.version) }
}
existingEnvironments.value = existingEnvs
}
override suspend fun findModulePath(module: Module): Path? = pyProjectToml(module)?.toNioPathOrNull()?.parent
private fun extractModule(moduleOrProject: ModuleOrProject): Module? =
when (moduleOrProject) {
is ModuleOrProject.ModuleAndProject -> moduleOrProject.module
else -> null
}
}

View File

@@ -264,12 +264,10 @@ internal fun Row.pythonInterpreterComboBox(
}
}
return cell
}
class PythonInterpreterComboBox(
val backingProperty: ObservableMutableProperty<PythonSelectableInterpreter?>,
internal class PythonInterpreterComboBox(
private val backingProperty: ObservableMutableProperty<PythonSelectableInterpreter?>,
val controller: PythonAddInterpreterModel,
val onPathSelected: (String) -> Unit,
) : ComboBox<PythonSelectableInterpreter?>() {

View File

@@ -16,6 +16,6 @@ interface UvLowLevel {
suspend fun listPackages(): Result<List<PythonPackage>>
suspend fun listOutdatedPackages(): Result<List<PythonOutdatedPackage>>
suspend fun installPackage(name: PythonPackageSpecification, options: List<String>): Result<Unit>
suspend fun uninstallPackage(name: PythonPackage): Result<Unit>
suspend fun installPackage(name: PythonPackageSpecification, options: List<String>, usePip: Boolean = false): Result<Unit>
suspend fun uninstallPackage(name: PythonPackage, usePip: Boolean = false): Result<Unit>
}

View File

@@ -5,24 +5,24 @@ import com.intellij.openapi.module.Module
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.util.NlsSafe
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.toNioPathOrNull
import com.intellij.platform.ide.progress.withBackgroundProgress
import com.intellij.util.PathUtil
import com.jetbrains.python.PyBundle
import com.jetbrains.python.icons.PythonIcons
import com.jetbrains.python.sdk.createSdk
import com.jetbrains.python.sdk.findAmongRoots
import com.jetbrains.python.sdk.setAssociationToModule
import com.jetbrains.python.sdk.*
import com.jetbrains.python.sdk.uv.impl.createUvCli
import com.jetbrains.python.sdk.uv.impl.createUvLowLevel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.nio.file.Path
import javax.swing.Icon
import kotlin.io.path.pathString
internal val Sdk.isUv: Boolean
get() = sdkAdditionalData is UvSdkAdditionalData
internal suspend fun uvLock(module: com.intellij.openapi.module.Module): VirtualFile? {
internal suspend fun uvLock(module: Module): VirtualFile? {
return withContext(Dispatchers.IO) {
findAmongRoots(module, UV_LOCK)
}
@@ -38,32 +38,54 @@ internal fun suggestedSdkName(basePath: Path): @NlsSafe String {
return "uv (${PathUtil.getFileName(basePath.pathString)})"
}
val UV_ICON = PythonIcons.UV
val UV_LOCK: String = "uv.lock"
val UV_ICON: Icon = PythonIcons.UV
const val UV_LOCK: String = "uv.lock"
// FIXME: move pyprojecttoml code out to common package
val PY_PROJECT_TOML: String = "pyproject.toml"
const val PY_PROJECT_TOML: String = "pyproject.toml"
suspend fun setupUvSdkUnderProgress(
module: Module,
projectPath: Path,
moduleOrProject: ModuleOrProject,
existingSdks: List<Sdk>,
python: Path?
python: Path?,
existingSdkWorkingDir: Path? = null,
usePip: Boolean = false,
): Result<Sdk> {
val uv = createUvLowLevel(projectPath, createUvCli())
val init = pyProjectToml(module) == null
val (pyProjectToml, moduleWorkingDirectory) = resolveWorkingDirectory(moduleOrProject)
val init = pyProjectToml == null
val uvWorkingDir = existingSdkWorkingDir ?: moduleWorkingDirectory
val uv = createUvLowLevel(uvWorkingDir, createUvCli())
val envExecutable =
withBackgroundProgress(module.project, PyBundle.message("python.sdk.dialog.title.setting.up.uv.environment"), true) {
uv.initializeEnvironment(init, python)
}.getOrElse {
return Result.failure(it)
if (existingSdkWorkingDir == null) {
withBackgroundProgress(moduleOrProject.project, PyBundle.message("python.sdk.dialog.title.setting.up.uv.environment"), true) {
uv.initializeEnvironment(init, python)
}.getOrElse {
return Result.failure(it)
}
}
else {
python
} ?: throw IllegalArgumentException("Python executable is required to setup uv environment")
val sdk = createSdk(envExecutable, existingSdks, projectPath.pathString, suggestedSdkName(projectPath), UvSdkAdditionalData())
val sdk = createSdk(envExecutable, existingSdks, moduleWorkingDirectory.pathString, suggestedSdkName(moduleWorkingDirectory), UvSdkAdditionalData(existingSdkWorkingDir, usePip))
sdk.onSuccess {
it.setAssociationToModule(module)
it.setAssociationToPath(moduleWorkingDirectory.pathString)
}
return sdk
}
private suspend fun resolveWorkingDirectory(moduleOrProject: ModuleOrProject): Pair<VirtualFile?, Path> {
var pyProjectToml: VirtualFile? = null
val workingDirectory = when (moduleOrProject) {
is ModuleOrProject.ModuleAndProject -> {
pyProjectToml = pyProjectToml(moduleOrProject.module)
pyProjectToml?.toNioPathOrNull()?.parent ?: moduleOrProject.module.basePath?.let { Path.of(it) }
}
else -> moduleOrProject.project.basePath?.let { Path.of(it) }
} ?: throw IllegalArgumentException("Path to module or working directory is required")
return Pair(pyProjectToml, workingDirectory)
}

View File

@@ -22,7 +22,7 @@ internal class UvPackageManager(project: Project, sdk: Sdk, val uv: UvLowLevel)
var outdatedPackages: Map<String, PythonOutdatedPackage> = emptyMap()
override suspend fun installPackageCommand(specification: PythonPackageSpecification, options: List<String>): Result<String> {
uv.installPackage(specification, options).getOrElse {
uv.installPackage(specification, options, (sdk.sdkAdditionalData as? UvSdkAdditionalData)?.usePip ?: false).getOrElse {
return Result.failure(it)
}
@@ -31,7 +31,7 @@ internal class UvPackageManager(project: Project, sdk: Sdk, val uv: UvLowLevel)
}
override suspend fun updatePackageCommand(specification: PythonPackageSpecification): Result<String> {
uv.installPackage(specification, emptyList()).getOrElse {
uv.installPackage(specification, emptyList(), (sdk.sdkAdditionalData as? UvSdkAdditionalData)?.usePip ?: false).getOrElse {
return Result.failure(it)
}
@@ -40,7 +40,7 @@ internal class UvPackageManager(project: Project, sdk: Sdk, val uv: UvLowLevel)
}
override suspend fun uninstallPackageCommand(pkg: PythonPackage): Result<String> {
uv.uninstallPackage(pkg).getOrElse {
uv.uninstallPackage(pkg, (sdk.sdkAdditionalData as? UvSdkAdditionalData)?.usePip ?: false).getOrElse {
return Result.failure(it)
}
@@ -64,7 +64,8 @@ class UvPackageManagerProvider : PythonPackageManagerProvider {
return null
}
val uv = createUvLowLevel(Path.of(project.basePath!!), createUvCli())
val uvWorkingDirectory = (sdk.sdkAdditionalData as? UvSdkAdditionalData)?.uvWorkingDirectory ?: Path.of(project.basePath!!)
val uv = createUvLowLevel(uvWorkingDirectory, createUvCli())
return UvPackageManager(project, sdk, uv)
}
}

View File

@@ -6,15 +6,30 @@ import com.jetbrains.python.sdk.flavors.PyFlavorData
import com.jetbrains.python.sdk.flavors.PythonFlavorProvider
import com.jetbrains.python.sdk.flavors.PythonSdkFlavor
import org.jdom.Element
import java.nio.file.Path
import javax.swing.Icon
import kotlin.io.path.pathString
class UvSdkAdditionalData : PythonSdkAdditionalData {
constructor() : super(UvSdkFlavor)
constructor(data: PythonSdkAdditionalData) : super(data)
val uvWorkingDirectory: Path?
val usePip: Boolean
constructor(uvWorkingDirectory: Path? = null, usePip: Boolean = false) : super(UvSdkFlavor) {
this.uvWorkingDirectory = uvWorkingDirectory
this.usePip = usePip
}
constructor(data: PythonSdkAdditionalData, uvWorkingDirectory: Path? = null, usePip: Boolean = false) : super(data) {
this.uvWorkingDirectory = uvWorkingDirectory
this.usePip = usePip
}
override fun save(element: Element) {
super.save(element)
element.setAttribute(IS_UV, "true")
element.setAttribute(UV_WORKING_DIR, uvWorkingDirectory?.pathString ?: "")
element.setAttribute(USE_PIP, usePip.toString())
}
companion object {
@@ -24,7 +39,9 @@ class UvSdkAdditionalData : PythonSdkAdditionalData {
fun load(element: Element): UvSdkAdditionalData? {
return when {
element.getAttributeValue(IS_UV) == "true" -> {
UvSdkAdditionalData().apply {
val uvWorkingDirectory = if (element.getAttributeValue(UV_WORKING_DIR).isNullOrEmpty()) null else Path.of(element.getAttributeValue(UV_WORKING_DIR))
val usePip = element.getAttributeValue(USE_PIP)?.toBoolean() ?: false
UvSdkAdditionalData(uvWorkingDirectory, usePip).apply {
load(element)
}
}
@@ -39,8 +56,8 @@ class UvSdkAdditionalData : PythonSdkAdditionalData {
}
}
object UvSdkFlavor : PythonSdkFlavor<PyFlavorData.Empty>() {
override fun getIcon() = UV_ICON
object UvSdkFlavor : CPythonSdkFlavor<PyFlavorData.Empty>() {
override fun getIcon(): Icon = UV_ICON
override fun getFlavorDataClass(): Class<PyFlavorData.Empty> = PyFlavorData.Empty::class.java
override fun isValidSdkPath(pathStr: String): Boolean {

View File

@@ -15,7 +15,7 @@ import java.nio.file.Path
import kotlin.io.path.exists
import kotlin.io.path.pathString
internal class UvLowLevelImpl(val cwd: Path, val uvCli: UvCli) : UvLowLevel {
internal class UvLowLevelImpl(val cwd: Path, private val uvCli: UvCli) : UvLowLevel {
override suspend fun initializeEnvironment(init: Boolean, python: Path?): Result<Path> {
val addPythonArg: (MutableList<String>) -> Unit = { args ->
python?.let {
@@ -93,25 +93,29 @@ internal class UvLowLevelImpl(val cwd: Path, val uvCli: UvCli) : UvLowLevel {
}
}
override suspend fun installPackage(spec: PythonPackageSpecification, options: List<String>): Result<Unit> {
val version = if (spec.versionSpecs.isNullOrBlank()) spec.name else "${spec.name}${spec.versionSpecs}"
uvCli.runUv(cwd, "add", version, *options.toTypedArray()).getOrElse {
override suspend fun installPackage(name: PythonPackageSpecification, options: List<String>, usePip: Boolean): Result<Unit> {
val version = if (name.versionSpecs.isNullOrBlank()) name.name else "${name.name}${name.versionSpecs}"
val command = if (usePip) listOf("pip", "install") else listOf("add")
uvCli.runUv(cwd, *command.toTypedArray(), version, *options.toTypedArray()).getOrElse {
return Result.failure(it)
}
return Result.success(Unit)
}
override suspend fun uninstallPackage(name: PythonPackage): Result<Unit> {
override suspend fun uninstallPackage(name: PythonPackage, usePip: Boolean): Result<Unit> {
// TODO: check if package is in dependencies
val result = uvCli.runUv(cwd, "remove", name.name)
if (result.isFailure) {
val command = if (usePip) listOf("pip", "uninstall") else listOf("remove")
val result = uvCli.runUv(cwd, *command.toTypedArray(), name.name)
if (result.isFailure && !usePip) {
// try just to uninstall
uvCli.runUv(cwd, "pip", "uninstall", name.name).onFailure {
return Result.failure(it)
}
return Result.success(Unit)
}
result.onFailure { return Result.failure(it) }
return Result.success(Unit)
}
}

View File

@@ -2,7 +2,6 @@
package com.jetbrains.python.sdk.uv.ui
import com.intellij.application.options.ModuleListCellRenderer
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.components.service
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.module.Module
@@ -25,6 +24,7 @@ import com.jetbrains.python.PyBundle
import com.jetbrains.python.PySdkBundle
import com.jetbrains.python.PythonModuleTypeBase
import com.jetbrains.python.newProject.collector.InterpreterStatisticsInfo
import com.jetbrains.python.sdk.ModuleOrProject
import com.jetbrains.python.sdk.PySdkSettings
import com.jetbrains.python.sdk.PythonSdkCoroutineService
import com.jetbrains.python.sdk.add.PyAddNewEnvPanel
@@ -161,7 +161,7 @@ class PyAddNewUvPanel(
setUvExecutable(it)
}
val sdk = runBlockingCancellable {
setupUvSdkUnderProgress(module, Path.of(path), existingSdks, Path.of(python))
setupUvSdkUnderProgress(ModuleOrProject.ModuleAndProject(module), existingSdks, Path.of(python))
}
sdk.onSuccess {