[pycharm] PY-79132 Replace base python with python version selection for uv

GitOrigin-RevId: b1b7cb2147244d2054ce9b3a8e2dfbd2bced567b
This commit is contained in:
David Lysenko
2025-08-05 15:11:12 +02:00
committed by intellij-monorepo-bot
parent 4d0b1dfecc
commit 04d67a09a7
7 changed files with 165 additions and 19 deletions

View File

@@ -51,6 +51,7 @@ jvm_library(
"@lib//:jetbrains-annotations",
"//python/python-pyproject:pyproject",
"//python/python-hatch:hatch",
"@lib//:io-github-z4kn4fein-semver-jvm",
],
runtime_deps = [":impl_resources"]
)
@@ -112,6 +113,7 @@ jvm_library(
"//platform/testFramework/junit5",
"//platform/testFramework/junit5:junit5_test_lib",
"@lib//:junit5",
"@lib//:io-github-z4kn4fein-semver-jvm",
],
runtime_deps = [":impl_resources"]
)

View File

@@ -49,5 +49,6 @@
<orderEntry type="module" module-name="intellij.python.hatch" />
<orderEntry type="module" module-name="intellij.platform.testFramework.junit5" scope="TEST" />
<orderEntry type="library" scope="TEST" name="JUnit5" level="project" />
<orderEntry type="library" name="io.github.z4kn4fein.semver.jvm" level="project" />
</component>
</module>

View File

@@ -10,6 +10,8 @@ import com.intellij.execution.target.TargetEnvironmentWizard
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.module.Module
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
@@ -61,6 +63,9 @@ private class AddLocalInterpreterAction(
private val onSdkCreated: Consumer<Sdk>,
) : AnAction(PyBundle.messagePointer("python.sdk.action.add.local.interpreter.text"), AllIcons.Nodes.HomeFolder), DumbAware {
override fun actionPerformed(e: AnActionEvent) {
runInEdt {
FileDocumentManager.getInstance().saveAllDocuments()
}
addLocalInterpreter(moduleOrProject, onSdkCreated)
}
}

View File

@@ -2,12 +2,26 @@
package com.jetbrains.python.sdk.add.v2.uv
import com.intellij.openapi.module.Module
import com.intellij.openapi.observable.properties.AtomicBooleanProperty
import com.intellij.openapi.observable.properties.ObservableMutableProperty
import com.intellij.openapi.observable.util.not
import com.intellij.openapi.project.Project
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.ui.ComboBox
import com.intellij.openapi.ui.validation.DialogValidationRequestor
import com.intellij.python.pyproject.PyProjectToml
import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.Panel
import com.intellij.ui.dsl.builder.bindItem
import com.intellij.ui.dsl.gridLayout.UnscaledGaps
import com.intellij.ui.dsl.listCellRenderer.textListCellRenderer
import com.intellij.uiDesigner.core.Spacer
import com.intellij.util.text.nullize
import com.intellij.util.ui.AsyncProcessIcon
import com.jetbrains.python.PyBundle.message
import com.jetbrains.python.errorProcessing.ErrorSink
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.getOrNull
import com.jetbrains.python.newProjectWizard.collector.PythonNewProjectWizardCollector
import com.jetbrains.python.sdk.add.v2.CustomNewEnvironmentCreator
import com.jetbrains.python.sdk.add.v2.PythonInterpreterSelectionMethod.SELECT_EXISTING
@@ -15,17 +29,27 @@ import com.jetbrains.python.sdk.add.v2.PythonMutableTargetAddInterpreterModel
import com.jetbrains.python.sdk.add.v2.PythonSupportedEnvironmentManagers.PYTHON
import com.jetbrains.python.sdk.add.v2.PythonSupportedEnvironmentManagers.UV
import com.jetbrains.python.sdk.add.v2.VenvExistenceValidationState
import com.jetbrains.python.sdk.add.v2.executableSelector
import com.jetbrains.python.sdk.basePath
import com.jetbrains.python.sdk.uv.impl.createUvCli
import com.jetbrains.python.sdk.uv.impl.createUvLowLevel
import com.jetbrains.python.sdk.uv.impl.setUvExecutable
import com.jetbrains.python.sdk.uv.setupNewUvSdkAndEnvUnderProgress
import com.jetbrains.python.statistics.InterpreterType
import com.jetbrains.python.venvReader.tryResolvePath
import io.github.z4kn4fein.semver.Version
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import java.awt.Dimension
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.exists
import kotlin.io.path.inputStream
internal class EnvironmentCreatorUv(
@@ -35,20 +59,103 @@ internal class EnvironmentCreatorUv(
) : CustomNewEnvironmentCreator("uv", model, errorSink) {
override val interpreterType: InterpreterType = InterpreterType.UV
override val executable: ObservableMutableProperty<String> = model.state.uvExecutable
private val executableFlow = MutableStateFlow(model.state.uvExecutable.get())
private lateinit var pythonVersion: ObservableMutableProperty<Version?>
private lateinit var versionComboBox: ComboBox<Version?>
private val loading = AtomicBooleanProperty(false)
init {
executable.afterChange {
executableFlow.value = it
}
}
override fun setupUI(panel: Panel, validationRequestor: DialogValidationRequestor) {
with(panel) {
row(message("sdk.create.python.version")) {
pythonVersion = propertyGraph.property(null)
versionComboBox = comboBox(listOf<Version?>(null), textListCellRenderer {
it?.let { "${it.major}.${it.minor}" } ?: "Default"
})
.bindItem(pythonVersion)
.enabledIf(loading.not())
.component
cell(AsyncProcessIcon("loader"))
.align(AlignX.LEFT)
.customize(UnscaledGaps(0))
.visibleIf(loading)
}
executableSelector(
executable = executable,
validationRequestor = validationRequestor,
labelText = message("sdk.create.custom.venv.executable.path", "uv"),
missingExecutableText = message("sdk.create.custom.venv.missing.text", "uv"),
installAction = createInstallFix(errorSink)
)
row("") {
venvExistenceValidationAlert(validationRequestor) {
onVenvSelectExisting()
}
}
}
}
override fun onShown(scope: CoroutineScope) {
super.onShown(scope)
model.projectPathFlows.projectPathWithDefault.onEach { projectPath ->
model
.projectPathFlows
.projectPathWithDefault
.combine(executableFlow) { projectPath, executable -> projectPath to executable }
.onEach { (projectPath, executable) ->
val venvPath = projectPath.resolve(".venv")
withContext(Dispatchers.IO) {
venvExistenceValidationState.set(
if (venvPath.exists())
VenvExistenceValidationState.Error(Paths.get(".venv"))
else
VenvExistenceValidationState.Invisible
)
}.launchIn(scope)
}
if (executable == "") {
return@onEach
}
versionComboBox.removeAllItems()
versionComboBox.addItem(null)
versionComboBox.selectedItem = null
try {
loading.set(true)
val pyProjectTomlPath = projectPath.resolve("pyproject.toml")
val pythonVersions = withContext(Dispatchers.IO) {
val versionRequest = if (pyProjectTomlPath.exists()) {
PyProjectToml.parse(pyProjectTomlPath.inputStream()).getOrNull()?.project?.requiresPython
} else {
null
}
val cli = createUvCli(Path.of(executable))
val uvLowLevel = createUvLowLevel(Path.of(""), cli)
uvLowLevel.listSupportedPythonVersions(versionRequest)
.getOr { return@withContext emptyList() }
.toList()
.sortedDescending()
}
pythonVersions.forEach {
versionComboBox.addItem(it)
}
} finally {
loading.set(false)
}
}
.launchIn(scope)
}
override fun onVenvSelectExisting() {
@@ -67,14 +174,20 @@ internal class EnvironmentCreatorUv(
setUvExecutable(savingPath)
}
override suspend fun setupEnvSdk(project: Project, module: Module?, baseSdks: List<Sdk>, projectPath: String, homePath: String?, installPackages: Boolean): PyResult<Sdk> {
override suspend fun setupEnvSdk(
project: Project,
module: Module?,
baseSdks: List<Sdk>,
projectPath: String,
homePath: String?,
installPackages: Boolean,
): PyResult<Sdk> {
val workingDir = module?.basePath?.let { tryResolvePath(it) } ?: project.basePath?.let { tryResolvePath(it) }
if (workingDir == null) {
return PyResult.localizedError("working dir is not specified for uv environment setup")
}
val python = homePath?.let { Path.of(it) }
return setupNewUvSdkAndEnvUnderProgress(project, workingDir, baseSdks, python)
return setupNewUvSdkAndEnvUnderProgress(project, workingDir, baseSdks, pythonVersion.get())
}
override suspend fun detectExecutable() {

View File

@@ -7,6 +7,7 @@ import com.jetbrains.python.packaging.common.NormalizedPythonPackageName
import com.jetbrains.python.packaging.common.PythonOutdatedPackage
import com.jetbrains.python.packaging.common.PythonPackage
import com.jetbrains.python.packaging.management.PythonPackageInstallRequest
import io.github.z4kn4fein.semver.Version
import org.jetbrains.annotations.ApiStatus
import java.nio.file.Path
@@ -17,9 +18,10 @@ interface UvCli {
@ApiStatus.Internal
interface UvLowLevel {
suspend fun initializeEnvironment(init: Boolean, python: Path?): PyResult<Path>
suspend fun initializeEnvironment(init: Boolean, version: Version?): PyResult<Path>
suspend fun listUvPythons(): PyResult<Set<Path>>
suspend fun listSupportedPythonVersions(versionRequest: String? = null): PyResult<Set<Version>>
/**
* Manage project dependencies by adding/removing them to the project along side installation

View File

@@ -14,6 +14,7 @@ import com.jetbrains.python.sdk.createSdk
import com.jetbrains.python.sdk.getOrCreateAdditionalData
import com.jetbrains.python.sdk.uv.impl.createUvCli
import com.jetbrains.python.sdk.uv.impl.createUvLowLevel
import io.github.z4kn4fein.semver.Version
import java.nio.file.Path
import javax.swing.Icon
import kotlin.io.path.exists
@@ -49,13 +50,13 @@ suspend fun setupNewUvSdkAndEnvUnderProgress(
suspend fun setupNewUvSdkAndEnv(
workingDir: Path,
existingSdks: List<Sdk>,
basePython: Path?,
version: Version?,
): PyResult<Sdk> {
val toml = workingDir.resolve(PY_PROJECT_TOML)
val init = !toml.exists()
val uv = createUvLowLevel(workingDir, createUvCli())
val envExecutable = uv.initializeEnvironment(init, basePython)
val envExecutable = uv.initializeEnvironment(init, version)
.getOr {
return it
}

View File

@@ -17,6 +17,7 @@ import com.jetbrains.python.sdk.uv.UvCli
import com.jetbrains.python.sdk.uv.UvLowLevel
import com.jetbrains.python.venvReader.VirtualEnvReader
import com.jetbrains.python.venvReader.tryResolvePath
import io.github.z4kn4fein.semver.Version
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.nio.file.Path
@@ -27,13 +28,14 @@ import kotlin.io.path.pathString
private const val NO_METADATA_MESSAGE = "does not contain a PEP 723 metadata tag"
private const val OUTDATED_ENV_MESSAGE = "The environment is outdated"
private val versionRegex = Regex("(\\d+\\.\\d+)\\.\\d+-.+\\s")
private class UvLowLevelImpl(val cwd: Path, private val uvCli: UvCli) : UvLowLevel {
override suspend fun initializeEnvironment(init: Boolean, python: Path?): PyResult<Path> {
override suspend fun initializeEnvironment(init: Boolean, version: Version?): PyResult<Path> {
val addPythonArg: (MutableList<String>) -> Unit = { args ->
python?.let {
version?.let {
args.add("--python")
args.add(python.pathString)
args.add("${version.major}.${version.minor}")
}
}
@@ -89,6 +91,26 @@ private class UvLowLevelImpl(val cwd: Path, private val uvCli: UvCli) : UvLowLev
return PyResult.success(pythons)
}
override suspend fun listSupportedPythonVersions(versionRequest: String?): PyResult<Set<Version>> {
val args = mutableListOf("python", "list")
if (versionRequest != null) {
args += versionRequest
}
val out = uvCli.runUv(cwd, *args.toTypedArray()).getOr { return it }
val matches = versionRegex.findAll(out)
return PyResult.success(
matches.map {
Version.parse(
it.groupValues[1],
strict = false
)
}.toSet()
)
}
override suspend fun listPackages(): PyExecResult<List<PythonPackage>> {
val out = uvCli.runUv(cwd, "pip", "list", "--format", "json")
.getOr { return it }