From 04d67a09a7ac9a6343c02aa759de1a503c838566 Mon Sep 17 00:00:00 2001 From: David Lysenko Date: Tue, 5 Aug 2025 15:11:12 +0200 Subject: [PATCH] [pycharm] PY-79132 Replace base python with python version selection for uv GitOrigin-RevId: b1b7cb2147244d2054ce9b3a8e2dfbd2bced567b --- python/ide/impl/BUILD.bazel | 2 + .../intellij.pycharm.community.ide.impl.iml | 1 + .../python/sdk/AddInterpreterActions.kt | 5 + .../sdk/add/v2/uv/EnvironmentCreatorUv.kt | 139 ++++++++++++++++-- python/src/com/jetbrains/python/sdk/uv/Uv.kt | 4 +- .../src/com/jetbrains/python/sdk/uv/UvExt.kt | 5 +- .../python/sdk/uv/impl/UvLowLevel.kt | 28 +++- 7 files changed, 165 insertions(+), 19 deletions(-) diff --git a/python/ide/impl/BUILD.bazel b/python/ide/impl/BUILD.bazel index 2020025660de..19fbdcd614c7 100644 --- a/python/ide/impl/BUILD.bazel +++ b/python/ide/impl/BUILD.bazel @@ -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"] ) diff --git a/python/ide/impl/intellij.pycharm.community.ide.impl.iml b/python/ide/impl/intellij.pycharm.community.ide.impl.iml index ecc42b6d007c..1af36c7fa057 100644 --- a/python/ide/impl/intellij.pycharm.community.ide.impl.iml +++ b/python/ide/impl/intellij.pycharm.community.ide.impl.iml @@ -49,5 +49,6 @@ + \ No newline at end of file diff --git a/python/src/com/jetbrains/python/sdk/AddInterpreterActions.kt b/python/src/com/jetbrains/python/sdk/AddInterpreterActions.kt index 02719c526d7c..afb7a5010423 100644 --- a/python/src/com/jetbrains/python/sdk/AddInterpreterActions.kt +++ b/python/src/com/jetbrains/python/sdk/AddInterpreterActions.kt @@ -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, ) : 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) } } 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 0a97d1f5f288..a8d043c8be9a 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 @@ -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 = model.state.uvExecutable + private val executableFlow = MutableStateFlow(model.state.uvExecutable.get()) + private lateinit var pythonVersion: ObservableMutableProperty + private lateinit var versionComboBox: ComboBox + 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(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 + .combine(executableFlow) { projectPath, executable -> projectPath to executable } + .onEach { (projectPath, executable) -> + val venvPath = projectPath.resolve(".venv") - model.projectPathFlows.projectPathWithDefault.onEach { projectPath -> - val venvPath = projectPath.resolve(".venv") - venvExistenceValidationState.set( - if (venvPath.exists()) - VenvExistenceValidationState.Error(Paths.get(".venv")) - else - VenvExistenceValidationState.Invisible - ) - }.launchIn(scope) + withContext(Dispatchers.IO) { + venvExistenceValidationState.set( + if (venvPath.exists()) + VenvExistenceValidationState.Error(Paths.get(".venv")) + else + VenvExistenceValidationState.Invisible + ) + } + 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, projectPath: String, homePath: String?, installPackages: Boolean): PyResult { + override suspend fun setupEnvSdk( + project: Project, + module: Module?, + baseSdks: List, + projectPath: String, + homePath: String?, + installPackages: Boolean, + ): PyResult { 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() { diff --git a/python/src/com/jetbrains/python/sdk/uv/Uv.kt b/python/src/com/jetbrains/python/sdk/uv/Uv.kt index 2a614723a7e1..faf13a5262f0 100644 --- a/python/src/com/jetbrains/python/sdk/uv/Uv.kt +++ b/python/src/com/jetbrains/python/sdk/uv/Uv.kt @@ -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 + suspend fun initializeEnvironment(init: Boolean, version: Version?): PyResult suspend fun listUvPythons(): PyResult> + suspend fun listSupportedPythonVersions(versionRequest: String? = null): PyResult> /** * Manage project dependencies by adding/removing them to the project along side installation diff --git a/python/src/com/jetbrains/python/sdk/uv/UvExt.kt b/python/src/com/jetbrains/python/sdk/uv/UvExt.kt index 32d22e77af93..0cc1d204c86e 100644 --- a/python/src/com/jetbrains/python/sdk/uv/UvExt.kt +++ b/python/src/com/jetbrains/python/sdk/uv/UvExt.kt @@ -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, - basePython: Path?, + version: Version?, ): PyResult { 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 } diff --git a/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt b/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt index 2023fab1dee2..3ff90040f292 100644 --- a/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt +++ b/python/src/com/jetbrains/python/sdk/uv/impl/UvLowLevel.kt @@ -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 { + override suspend fun initializeEnvironment(init: Boolean, version: Version?): PyResult { val addPythonArg: (MutableList) -> 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> { + 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> { val out = uvCli.runUv(cwd, "pip", "list", "--format", "json") .getOr { return it }