Python: add UI for system pythons

See `spi.kt`

Show provided pythons in UI And sort them.
See sort logic and a test.

GitOrigin-RevId: 7b98be47f41653d73400a64c65fbbfe3e1d78ee2
This commit is contained in:
Ilya.Kazakevich
2025-02-07 02:09:07 +01:00
committed by intellij-monorepo-bot
parent de41298dc0
commit c6e616043d
10 changed files with 122 additions and 24 deletions

View File

@@ -26,15 +26,16 @@ import kotlin.io.path.relativeTo
class PythonWithLanguageLevelImpl internal constructor(
override val pythonBinary: PythonBinary,
override val languageLevel: LanguageLevel,
) : PythonWithLanguageLevel, Comparable<PythonWithLanguageLevelImpl> {
) : PythonWithLanguageLevel, Comparable<PythonWithLanguageLevel> {
companion object {
private val concurrentLimit = Semaphore(permits = 4)
/**
* Like [createByPythonBinary] but runs in parallel up to [concurrentLimit]
* @return python path -> python with language level sorted from highest to lowest.
*/
suspend fun createByPythonBinaries(pythonBinaries: Collection<PythonBinary>): Collection<Pair<PythonBinary, Result<PythonWithLanguageLevelImpl, @Nls String>>> =
suspend fun createByPythonBinaries(pythonBinaries: Collection<PythonBinary>): Collection<Pair<PythonBinary, Result<PythonWithLanguageLevel, @Nls String>>> =
coroutineScope {
pythonBinaries.map {
async {
@@ -43,7 +44,7 @@ class PythonWithLanguageLevelImpl internal constructor(
}
}
}.awaitAll()
}
}.sortedBy { it.first }
suspend fun createByPythonBinary(pythonBinary: PythonBinary): Result<PythonWithLanguageLevelImpl, @Nls String> {
val languageLevel = pythonBinary.validatePythonAndGetVersion().getOr { return it }
@@ -80,6 +81,6 @@ class PythonWithLanguageLevelImpl internal constructor(
return "$pythonString ($languageLevel)"
}
// TODO: DOC backward
override fun compareTo(other: PythonWithLanguageLevelImpl): Int = other.languageLevel.compareTo(languageLevel)
// Backward: first python is the highest
override fun compareTo(other: PythonWithLanguageLevel): Int = languageLevel.compareTo(other.languageLevel) * -1
}

View File

@@ -5,7 +5,7 @@ import com.jetbrains.python.PythonBinary
import com.jetbrains.python.psi.LanguageLevel
import org.jetbrains.annotations.Nls
interface PythonWithLanguageLevel {
interface PythonWithLanguageLevel : Comparable<PythonWithLanguageLevel> {
val pythonBinary: PythonBinary
val languageLevel: LanguageLevel

View File

@@ -29,5 +29,8 @@
<orderEntry type="module" module-name="intellij.python.community.impl.installer" />
<orderEntry type="module" module-name="intellij.python.community.impl.venv" scope="TEST" />
<orderEntry type="module" module-name="intellij.python.community.testFramework.testEnv" scope="TEST" />
<orderEntry type="library" scope="TEST" name="io.mockk" level="project" />
<orderEntry type="library" scope="TEST" name="io.mockk.jvm" level="project" />
<orderEntry type="module" module-name="intellij.platform.testFramework" scope="TEST" />
</component>
</module>

View File

@@ -5,12 +5,12 @@ import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.platform.eel.EelApi
import com.intellij.platform.eel.provider.localEel
import com.intellij.python.community.services.internal.impl.PythonWithLanguageLevelImpl
import com.intellij.python.community.services.shared.PythonWithLanguageLevel
import com.jetbrains.python.PythonBinary
import com.jetbrains.python.Result
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.Nls
import javax.swing.Icon
/**
* Service to register and obtain [SystemPython]s
@@ -18,7 +18,7 @@ import org.jetbrains.annotations.Nls
@ApiStatus.NonExtendable
sealed interface SystemPythonService {
/**
* @return system pythons installed on OS in order from highest (hence, the first one is usually the best one)
* @return system pythons installed on OS sorted by type, then by lang.level: in order from highest (hence, the first one is usually the best one)
*/
suspend fun findSystemPythons(eelApi: EelApi = localEel): List<SystemPython>
@@ -42,11 +42,36 @@ fun SystemPythonService(): SystemPythonService = ApplicationManager.getApplicati
/**
* Python installed on OS.
* [pythonBinary] is guaranteed to be usable and have [languageLevel] at the moment of instance creation.
* Use [ui] to customize view.
*
* When sorted, sorted first by [ui], then by [languageLevel] (from the highest)
*
* Instances could be obtained with [SystemPythonService]
*/
@JvmInline
value class SystemPython internal constructor(private val impl: PythonWithLanguageLevelImpl) : PythonWithLanguageLevel by impl
class SystemPython internal constructor(private val impl: PythonWithLanguageLevel, val ui: UICustomization?) : PythonWithLanguageLevel by impl {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SystemPython
if (impl != other.impl) return false
if (ui != other.ui) return false
return true
}
override fun hashCode(): Int {
var result = impl.hashCode()
result = 31 * result + (ui?.hashCode() ?: 0)
return result
}
override fun toString(): String {
return "SystemPython(impl=$impl, ui=$ui)"
}
}
/**
* Tool to install python on OS.
@@ -60,4 +85,12 @@ sealed interface PythonInstallerService {
*/
@ApiStatus.Experimental
suspend fun installLatestPython(): Result<Unit, String>
}
}
data class UICustomization(
/**
* i.e: "UV" for pythons found by UV
*/
val title: @Nls String,
val icon: Icon? = null,
)

View File

@@ -32,7 +32,7 @@ internal class SystemPythonServiceImpl : SystemPythonService, SimplePersistentSt
override suspend fun registerSystemPython(pythonPath: PythonBinary): Result<SystemPython, @Nls String> {
val impl = PythonWithLanguageLevelImpl.createByPythonBinary(pythonPath).getOr { return it }
state.userProvidedPythons.add(pythonPath)
return Result.success(SystemPython(impl))
return Result.success(SystemPython(impl, null))
}
override fun getInstaller(eelApi: EelApi): PythonInstallerService? =
@@ -47,9 +47,18 @@ internal class SystemPythonServiceImpl : SystemPythonService, SimplePersistentSt
}
else emptyList()
val pythonsUi = mutableMapOf<PythonBinary, UICustomization>()
val pythonsFromExtensions = SystemPythonProvider.EP
.extensionList
.flatMap { it.findSystemPythons(eelApi).getOrNull() ?: emptyList() }.filter { it.getEelDescriptor().upgrade() == eelApi }
.flatMap { provider ->
val pythons = provider.findSystemPythons(eelApi).getOrNull() ?: emptyList()
val ui = provider.uiCustomization
if (ui != null) {
pythons.forEach { pythonsUi[it] = ui }
}
pythons
}.filter { it.getEelDescriptor().upgrade() == eelApi }
val badPythons = mutableSetOf<PythonBinary>()
val pythons = corePythons + pythonsFromExtensions + state.userProvidedPythons.filter { it.getEelDescriptor() == eelApi.descriptor }
@@ -57,7 +66,7 @@ internal class SystemPythonServiceImpl : SystemPythonService, SimplePersistentSt
val result = PythonWithLanguageLevelImpl.createByPythonBinaries(pythons.toSet())
.mapNotNull { (python, r) ->
when (r) {
is Result.Success -> SystemPython(r.result)
is Result.Success -> SystemPython(r.result, pythonsUi[r.result.pythonBinary])
is Result.Failure -> {
fileLogger().info("Skipping $python : ${r.error}")
badPythons.add(python)
@@ -68,7 +77,7 @@ internal class SystemPythonServiceImpl : SystemPythonService, SimplePersistentSt
}.toSet()
// Remove stale pythons from cache
state.userProvidedPythons.removeAll(badPythons)
return@withContext result.sortedByDescending { it.languageLevel }
return@withContext result.sorted()
}

View File

@@ -14,5 +14,10 @@ interface SystemPythonProvider {
val EP: ExtensionPointName<SystemPythonProvider> = ExtensionPointName<SystemPythonProvider>("Pythonid.systemPythonProvider")
}
/**
* You can optionally customize how your pythons are displayed
*/
val uiCustomization: UICustomization? get() = null
suspend fun findSystemPythons(eelApi: EelApi): Result<Set<PythonBinary>>
}

View File

@@ -1,15 +1,28 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.python.junit5Tests.env.systemPython.impl
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.python.community.impl.venv.createVenv
import com.intellij.python.community.services.systemPython.SystemPythonProvider
import com.intellij.python.community.services.systemPython.SystemPythonService
import com.intellij.python.community.services.systemPython.UICustomization
import com.intellij.python.junit5Tests.framework.env.PyEnvTestCase
import com.intellij.python.junit5Tests.framework.env.PythonBinaryPath
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.junit5.TestDisposable
import com.intellij.testFramework.registerExtension
import com.jetbrains.python.PythonBinary
import com.jetbrains.python.getOrThrow
import io.mockk.coEvery
import io.mockk.mockk
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
@PyEnvTestCase
class EnvProviderTest {
@@ -23,8 +36,24 @@ class EnvProviderTest {
if (systemPythons.size > 1) {
val best = systemPythons.first()
for (python in systemPythons.subList(1, systemPythonBinaries.size)) {
Assertions.assertTrue(python.languageLevel <= best.languageLevel, "$best is the first, bust worse than $python")
assertTrue(python.languageLevel <= best.languageLevel, "$best is the first, bust worse than $python")
}
}
}
@Test
fun testProviderWithUi(
@TestDisposable disposable: Disposable,
@PythonBinaryPath python: PythonBinary,
@TempDir venvDir: Path,
): Unit = timeoutRunBlocking {
val venvPython = createVenv(python, venvDir).getOrThrow()
val ui = UICustomization("myui")
val provider = mockk<SystemPythonProvider>()
coEvery { provider.findSystemPythons(any()) } returns Result.success(setOf(venvPython))
coEvery { provider.uiCustomization } returns ui
ApplicationManager.getApplication().registerExtension(SystemPythonProvider.EP, provider, disposable)
val python = SystemPythonService().findSystemPythons().first { it.pythonBinary == venvPython }
assertEquals(ui, python.ui, "Wrong UI")
}
}

View File

@@ -12,7 +12,9 @@ import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.util.NlsSafe
import com.intellij.python.community.services.internal.impl.PythonWithLanguageLevelImpl
import com.intellij.python.community.services.shared.PythonWithLanguageLevel
import com.intellij.python.community.services.systemPython.SystemPython
import com.intellij.python.community.services.systemPython.SystemPythonService
import com.intellij.python.community.services.systemPython.UICustomization
import com.jetbrains.python.PyBundle.message
import com.jetbrains.python.configuration.PyConfigurableInterpreterList
import com.jetbrains.python.errorProcessing.ErrorSink
@@ -157,13 +159,13 @@ abstract class PythonAddInterpreterModel(params: PyInterpreterModelParams, priva
}
// System (base) pythons
val system: List<PythonWithLanguageLevel> = systemPythonService.findSystemPythons()
val system: List<SystemPython> = systemPythonService.findSystemPythons()
// Python + isBase. Both: system and venv.
val detected = (venvs.map { Pair(it, false) } + system.map { Pair(it, true) })
val detected = (venvs.map { Triple(it, false, null) } + system.map { Triple(it, true, it.ui) })
.filterNot { (python, _) -> python.pythonBinary in existingSdkPaths }
.map { (python, base) -> DetectedSelectableInterpreter(python.pythonBinary.pathString, python.languageLevel, base) }
.sortedByDescending { it.languageLevel }
.map { (python, base, ui) -> DetectedSelectableInterpreter(python.pythonBinary.pathString, python.languageLevel, base, ui) }
.sorted()
withContext(uiContext) {
installable = filteredInstallable
@@ -285,6 +287,7 @@ sealed class PythonSelectableInterpreter {
abstract val homePath: String
abstract val languageLevel: LanguageLevel
open val uiCustomization: UICustomization? = null
override fun toString(): String =
"PythonSelectableInterpreter(homePath='$homePath')"
}
@@ -300,8 +303,16 @@ class ExistingSelectableInterpreter(val sdk: Sdk, override val languageLevel: La
/**
* [isBase] is a system interpreter, see [isBasePython]
*/
class DetectedSelectableInterpreter(override val homePath: String, override val languageLevel: LanguageLevel, private val isBase: Boolean) : PythonSelectableInterpreter() {
class DetectedSelectableInterpreter(override val homePath: String, override val languageLevel: LanguageLevel, private val isBase: Boolean, override val uiCustomization: UICustomization? = null) : PythonSelectableInterpreter(), Comparable<DetectedSelectableInterpreter> {
override suspend fun isBasePython(): Boolean = isBase
override fun compareTo(other: DetectedSelectableInterpreter): Int {
// First by type
val byType = (uiCustomization?.title ?: "").compareTo(other.uiCustomization?.title ?: "")
// Then from the highest python to the lowest
return if (byType != 0) byType else (languageLevel.compareTo(other.languageLevel) * -1)
}
}
class ManuallyAddedSelectableInterpreter(override val homePath: String, override val languageLevel: LanguageLevel) : PythonSelectableInterpreter()

View File

@@ -149,9 +149,10 @@ class PythonNewEnvironmentDialogNavigator {
internal fun SimpleColoredComponent.customizeForPythonInterpreter(interpreter: PythonSelectableInterpreter) {
when (interpreter) {
is DetectedSelectableInterpreter, is ManuallyAddedSelectableInterpreter -> {
icon = IconLoader.getTransparentIcon(PythonPsiApiIcons.Python)
icon = IconLoader.getTransparentIcon(interpreter.uiCustomization?.icon ?: PythonPsiApiIcons.Python)
append(replaceHomePathToTilde(interpreter.homePath))
append(" " + message("sdk.rendering.detected.grey.text"), SimpleTextAttributes.GRAYED_SMALL_ATTRIBUTES)
val message = interpreter.uiCustomization?.title ?: message("sdk.rendering.detected.grey.text")
append(" $message", SimpleTextAttributes.GRAYED_SMALL_ATTRIBUTES)
}
is InstallableSelectableInterpreter -> {
icon = AllIcons.Actions.Download

View File

@@ -1,10 +1,13 @@
// 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.uv
import com.intellij.openapi.util.NlsSafe
import com.intellij.platform.eel.EelApi
import com.intellij.platform.eel.provider.localEel
import com.intellij.python.community.services.systemPython.SystemPythonProvider
import com.intellij.python.community.services.systemPython.UICustomization
import com.jetbrains.python.PythonBinary
import com.jetbrains.python.icons.PythonIcons
import com.jetbrains.python.sdk.uv.impl.createUvLowLevel
import com.jetbrains.python.sdk.uv.impl.hasUvExecutable
import java.nio.file.Path
@@ -19,4 +22,7 @@ internal class UvSystemPythonProvider : SystemPythonProvider {
val uv = createUvLowLevel(Path.of("."))
return uv.listUvPythons()
}
@Suppress("HardCodedStringLiteral") // tool name is untranslatable
override val uiCustomization: UICustomization = UICustomization("uv", PythonIcons.UV)
}