[python] show execution command and outputs for conda runner in case of parsing failures (PY-76274)

+ ZeroCodeStdoutParserTransformer  
 + ZeroCodeJsonParserTransformer
 + add transformer showcase


Merge-request: IJ-MR-172758
Merged-by: Vitaly Legchilkin <Vitaly.Legchilkin@jetbrains.com>

GitOrigin-RevId: 1f293e8725a986eaed2a6050a3c6a240441f09fe
This commit is contained in:
Vitaly Legchilkin
2025-08-19 08:09:48 +00:00
committed by intellij-monorepo-bot
parent bc2fcf0c88
commit eb336fedc2
9 changed files with 280 additions and 95 deletions
@@ -5,10 +5,6 @@ import com.intellij.openapi.diagnostic.Logger
import com.jetbrains.python.Result.Failure
import com.jetbrains.python.Result.Success
import com.jetbrains.python.errorProcessing.MessageError
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.packaging.PyExecutionException
import kotlinx.coroutines.CancellationException
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.Nls
/**
@@ -114,29 +110,9 @@ sealed class Result<out SUCC, out ERR> {
fun <S> success(value: S): Success<S> = Success(value)
fun <E> failure(error: E): Failure<E> = Failure(error)
fun localizedError(message: @Nls String): Failure<MessageError> = failure(MessageError(message))
@ApiStatus.Internal
inline fun <T> runCatching(body: () -> T): PyResult<T> = pyRunCatching(body)
}
}
@Suppress("UsagesOfObsoleteApi")
@ApiStatus.Internal
inline fun <T> pyRunCatching(body: () -> T): PyResult<T> {
return try {
PyResult.success(body())
}
catch (t: PyExecutionException) {
PyResult.failure(t.pyError)
}
catch (t: CancellationException) {
throw t
}
catch (t: IllegalArgumentException) {
//Parse deserialization exceptions
PyResult.localizedError(t.localizedMessage)
}
}
/**
* Maps success result to another one with same error
+12
View File
@@ -1,6 +1,12 @@
### auto-generated section `build intellij.python.community.execService` start
load("//build:compiler-options.bzl", "create_kotlinc_options")
load("@rules_jvm//:jvm.bzl", "jvm_library", "jvm_resources")
create_kotlinc_options(
name = "custom_community-execService",
opt_in = ["kotlin.time.ExperimentalTime"]
)
jvm_resources(
name = "community-execService_resources",
files = glob(["resources/**/*"]),
@@ -12,6 +18,7 @@ jvm_library(
module_name = "intellij.python.community.execService",
visibility = ["//visibility:public"],
srcs = glob(["src/**/*.kt", "src/**/*.java"], allow_empty = True),
kotlinc_opts = ":custom_community-execService",
deps = [
"@lib//:kotlin-stdlib",
"@lib//:jetbrains-annotations",
@@ -25,6 +32,8 @@ jvm_library(
"//platform/util/progress",
"//platform/execution",
"//platform/projectModel-api:projectModel",
"@lib//:kotlinx-serialization-json",
"@lib//:kotlinx-serialization-core",
],
runtime_deps = [":community-execService_resources"]
)
@@ -33,6 +42,7 @@ jvm_library(
name = "community-execService_test_lib",
visibility = ["//visibility:public"],
srcs = glob(["tests/**/*.kt", "tests/**/*.java"], allow_empty = True),
kotlinc_opts = ":custom_community-execService",
associates = [":community-execService"],
deps = [
"@lib//:kotlin-stdlib",
@@ -57,6 +67,8 @@ jvm_library(
"//platform/util/progress",
"//platform/execution",
"//platform/projectModel-api:projectModel",
"@lib//:kotlinx-serialization-json",
"@lib//:kotlinx-serialization-core",
],
runtime_deps = [":community-execService_resources"]
)
@@ -1,5 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="FacetManager">
<facet type="kotlin-language" name="Kotlin">
<configuration version="5" platform="JVM 17" allPlatforms="JVM [17]" useProjectSettings="false">
<compilerSettings>
<option name="additionalArguments" value="-Xjvm-default=all -opt-in=kotlin.time.ExperimentalTime" />
</compilerSettings>
<compilerArguments>
<stringArguments>
<stringArg name="jvmTarget" arg="17" />
<stringArg name="apiVersion" arg="2.2" />
<stringArg name="languageVersion" arg="2.2" />
</stringArguments>
<arrayArguments>
<arrayArg name="pluginClasspaths">
<args>$KOTLIN_BUNDLED$/lib/kotlinx-serialization-compiler-plugin.jar</args>
</arrayArg>
</arrayArguments>
</compilerArguments>
</configuration>
</facet>
</component>
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
@@ -28,5 +49,7 @@
<orderEntry type="module" module-name="intellij.platform.util.progress" />
<orderEntry type="module" module-name="intellij.platform.execution" />
<orderEntry type="module" module-name="intellij.platform.projectModel" />
<orderEntry type="library" name="kotlinx-serialization-json" level="project" />
<orderEntry type="library" name="kotlinx-serialization-core" level="project" />
</component>
</module>
@@ -164,6 +164,20 @@ object ZeroCodeStdoutTransformer : ProcessOutputTransformer<String> {
if (processOutput.exitCode == 0) Result.success(processOutput.stdoutString.trim()) else Result.failure(null)
}
/**
* A process output transformer that parses standard output using a provided parser function.
*
* @param T The type of the result produced by the transformer.
* @param stdoutParser A function that takes a string (standard output) and parses it into a [Result] containing
* either a successfully parsed result of type [T], or a failure with an optional [NlsSafe] error message.
*/
open class ZeroCodeStdoutParserTransformer<T>(val stdoutParser: (String) -> Result<T, @NlsSafe String?>) : ProcessOutputTransformer<T> {
override fun invoke(processOutput: EelProcessExecutionResult): Result<T, @NlsSafe String?> {
val data = ZeroCodeStdoutTransformer.invoke(processOutput).getOr { return it }
return stdoutParser(data)
}
}
/**
* @property[env] Environment variables to be applied with the process run
@@ -0,0 +1,50 @@
// 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.community.execService
import com.intellij.serialization.SerializationException
import com.jetbrains.python.Result
import kotlinx.serialization.json.Json
/**
* A functional interface defining a contract for parsing JSON strings into instances of type [T].
*
* Potential exceptions:
* - [IllegalArgumentException]: Thrown if the input JSON string is invalid or improper.
* - [SerializationException]: Thrown if there is a failure during the deserialization process.
*
* @param T The type of object expected as the result of JSON parsing.
*/
fun interface JsonParser<T> {
fun parseJson(rawJson: String): T
}
/**
* Parses the JSON from the process execution result using the provided parser function.
* [jsonParser] may throw [SerializationException] or [IllegalArgumentException] if output is invalid,
* in this case the parsing exception localized message will be returned.
*
*
* @param jsonParser Function that converts stdout string to type [T].
* @return A [Result] containing either the successfully parsed [T] object, or a parsing failure message
*/
class ZeroCodeJsonParserTransformer<T>(jsonParser: JsonParser<T>) : ZeroCodeStdoutParserTransformer<T>(
{
try {
Result.success(jsonParser.parseJson(it))
}
catch (t: SerializationException) {
Result.failure(t.localizedMessage)
}
catch (t: IllegalArgumentException) {
Result.failure(t.localizedMessage)
}
}
) {
companion object {
inline operator fun <reified T> invoke(json: Json): ZeroCodeJsonParserTransformer<T> {
return ZeroCodeJsonParserTransformer(JsonParser { json.decodeFromString(it) })
}
}
}
@@ -4,7 +4,10 @@ package com.intellij.python.junit5Tests.unit.alsoWin
import com.intellij.platform.eel.EelPlatform
import com.intellij.platform.eel.getShell
import com.intellij.platform.eel.provider.asNioPath
import com.intellij.platform.eel.provider.utils.*
import com.intellij.platform.eel.provider.utils.asEelChannel
import com.intellij.platform.eel.provider.utils.consumeAsEelChannel
import com.intellij.platform.eel.provider.utils.readWholeText
import com.intellij.platform.eel.provider.utils.sendWholeText
import com.intellij.platform.testFramework.junit5.eel.params.api.EelHolder
import com.intellij.platform.testFramework.junit5.eel.params.api.EelSource
import com.intellij.platform.testFramework.junit5.eel.params.api.TestApplicationWithEel
@@ -97,45 +100,6 @@ class ExecServiceShowCaseTest {
}
}
@ParameterizedTest
@EelSource
fun testDataTransformer(eelHolder: EelHolder): Unit = timeoutRunBlocking {
val eel = eelHolder.eel
data class Record(val name: String, val age: Int)
val (shell, execArg) = eel.exec.getShell()
val args = Args(execArg, "echo Alice,25 && echo Bob,48")
val records = ExecService().execute((BinOnEel(shell.asNioPath())), args) { output ->
val stdout = output.stdoutString.trim()
when {
output.exitCode == 123 -> {
Result.success(emptyList())
}
output.exitCode != 0 -> {
Result.failure(null)
}
stdout == "SOME_BUSINESS_ERROR" -> {
Result.failure("My Business Error Description")
}
else -> {
val records = stdout.lines().map { it.trim() }.map {
val (name, age) = it.split(',')
Record(name, age.trim().toInt())
}
Result.success(records)
}
}
}
assertEquals(
listOf(Record("Alice", 25), Record("Bob", 48)),
records.getOrThrow()
)
}
@ParameterizedTest
@EelSource
fun testSunnyDay(eelHolder: EelHolder): Unit = timeoutRunBlocking {
@@ -0,0 +1,104 @@
// 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.unit.alsoWin
import com.intellij.platform.eel.EelPlatform
import com.intellij.platform.eel.getShell
import com.intellij.platform.eel.provider.asNioPath
import com.intellij.platform.eel.provider.utils.stdoutString
import com.intellij.platform.testFramework.junit5.eel.params.api.EelHolder
import com.intellij.platform.testFramework.junit5.eel.params.api.EelSource
import com.intellij.platform.testFramework.junit5.eel.params.api.TestApplicationWithEel
import com.intellij.python.community.execService.*
import com.intellij.testFramework.common.timeoutRunBlocking
import com.jetbrains.python.Result
import com.jetbrains.python.getOrThrow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.condition.DisabledOnOs
import org.junit.jupiter.api.condition.OS
import org.junit.jupiter.params.ParameterizedTest
/**
* How to use [ProcessOutputTransformer] inheritors.
*/
@TestApplicationWithEel(osesMayNotHaveRemoteEels = [OS.WINDOWS, OS.LINUX, OS.MAC])
class ProcessOutputTransformerShowCaseTest {
@ParameterizedTest
@EelSource
fun testProcessOutputTransformer(eelHolder: EelHolder): Unit = timeoutRunBlocking {
val eel = eelHolder.eel
data class Record(val name: String, val age: Int)
val (shell, execArg) = eel.exec.getShell()
val args = Args(execArg, "echo Alice,25 && echo Bob,48")
val records = ExecService().execute((BinOnEel(shell.asNioPath())), args) { output ->
val stdout = output.stdoutString.trim()
when {
output.exitCode == 123 -> {
Result.success(emptyList())
}
output.exitCode != 0 -> {
Result.failure(null)
}
stdout == "SOME_BUSINESS_ERROR" -> {
Result.failure("My Business Error Description")
}
else -> {
val records = stdout.lines().map { it.trim() }.map {
val (name, age) = it.split(',')
Record(name, age.trim().toInt())
}
Result.success(records)
}
}
}
assertEquals(
listOf(Record("Alice", 25), Record("Bob", 48)),
records.getOrThrow()
)
}
@ParameterizedTest
@EelSource
@DisabledOnOs(OS.WINDOWS, disabledReason = "echo command creates extra escaping on Windows")
fun testZeroCodeJsonParserTransformer(eelHolder: EelHolder): Unit = timeoutRunBlocking {
val eel = eelHolder.eel
@Serializable
data class Record(val name: String, val age: Int)
val testData = listOf(Record("Alice", 25), Record("Bob", 48))
val (shell, execArg) = eel.exec.getShell()
val json = Json { ignoreUnknownKeys = true }
val serialized = json.encodeToString(testData)
val args = Args(
execArg,
if (eel.platform is EelPlatform.Windows) "echo $serialized" else "echo '$serialized'"
)
val recordsViaManualDecode = ExecService().execute(
binary = (BinOnEel(shell.asNioPath())),
args = args,
processOutputTransformer = ZeroCodeJsonParserTransformer { jsonString ->
json.decodeFromString<List<Record>>(jsonString)
}
)
assertEquals(testData, recordsViaManualDecode.getOrThrow())
val recordsViaGenericTransform = ExecService().execute(
binary = (BinOnEel(shell.asNioPath())),
args = args,
processOutputTransformer = ZeroCodeJsonParserTransformer<List<Record>>(json)
)
assertEquals(testData, recordsViaGenericTransform.getOrThrow())
}
}
@@ -1,11 +1,7 @@
// 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
import com.intellij.python.community.execService.Args
import com.intellij.python.community.execService.BinOnEel
import com.intellij.python.community.execService.ExecOptions
import com.intellij.python.community.execService.ExecService
import com.intellij.python.community.execService.execGetStdout
import com.intellij.python.community.execService.*
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.PyResult
import org.jetbrains.annotations.ApiStatus.Internal
@@ -13,6 +9,16 @@ import java.nio.file.Path
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
@Internal
suspend fun runExecutableWithProgress(
executable: Path, workDir: Path?,
timeout: Duration = 10.minutes,
env: Map<String, String> = emptyMap(),
vararg args: String,
): PyResult<String> {
return runExecutableWithProgress(executable, workDir, timeout, env, *args, transformer = ZeroCodeStdoutTransformer)
}
/**
* Executes a given executable with specified arguments within an optional project directory.
@@ -24,12 +30,26 @@ import kotlin.time.Duration.Companion.minutes
* @return A [Result] object containing the output of the command execution.
*/
@Internal
suspend fun runExecutableWithProgress(
suspend fun <T> runExecutableWithProgress(
executable: Path, workDir: Path?,
timeout: Duration = 10.minutes,
env: Map<String, String> = emptyMap(),
vararg args: String,
): PyResult<String> {
transformer: ProcessOutputTransformer<T>,
): PyResult<T> {
val execOptions = ExecOptions(timeout = timeout, env = env)
return ExecService().execGetStdout(BinOnEel(executable, workDir), Args(*args), execOptions)
val errorHandlerTransformer: ProcessOutputTransformer<T> = { output ->
when {
output.exitCode == 0 -> transformer.invoke(output)
else -> Result.failure(null)
}
}
return ExecService().execute(
binary = BinOnEel(executable, workDir),
args = Args(*args),
options = execOptions,
processOutputTransformer = errorHandlerTransformer
)
}
@@ -4,12 +4,14 @@ package com.jetbrains.python.sdk.conda.execution
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.platform.eel.isWindows
import com.intellij.platform.eel.provider.getEelDescriptor
import com.intellij.python.community.execService.ProcessOutputTransformer
import com.intellij.python.community.execService.ZeroCodeJsonParserTransformer
import com.intellij.python.community.execService.ZeroCodeStdoutTransformer
import com.intellij.util.ShellEnvironmentReader
import com.jetbrains.python.PyBundle
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.packaging.common.PythonOutdatedPackage
import com.jetbrains.python.packaging.common.PythonPackage
import com.jetbrains.python.pyRunCatching
import com.jetbrains.python.sdk.conda.execution.models.CondaEnvInfo
import com.jetbrains.python.sdk.flavors.conda.PyCondaEnvIdentity
import com.jetbrains.python.sdk.runExecutableWithProgress
@@ -24,62 +26,82 @@ import kotlin.time.Duration.Companion.minutes
object CondaExecutor {
suspend fun createNamedEnv(condaPath: Path, envName: String, pythonVersion: String): PyResult<Unit> {
val args = listOf("create", "-y", "-n", envName, "python=${pythonVersion}")
return runConda(condaPath, args, null).mapSuccess { }
return runConda(
condaPath, args, null
) { PyResult.success(Unit) }
}
suspend fun createUnnamedEnv(condaPath: Path, envPrefix: String, pythonVersion: String): PyResult<Unit> {
val args = listOf("create", "-y", "-p", envPrefix, "python=${pythonVersion}")
return runConda(condaPath, args, null).mapSuccess { }
return runConda(
condaPath, args, null
) { PyResult.success(Unit) }
}
suspend fun createFileEnv(condaPath: Path, environmentYaml: Path): PyResult<Unit> {
val args = listOf("env", "create", "-f", environmentYaml.pathString)
return runConda(condaPath, args, null).mapSuccess { }
return runConda(
condaPath, args, null
) { PyResult.success(Unit) }
}
suspend fun updateFromEnvironmentFile(condaPath: Path, envYmlPath: String, envIdentity: PyCondaEnvIdentity): PyResult<Unit> {
val args = listOf("env", "update", "--file", envYmlPath, "--prune")
return runConda(condaPath, args, envIdentity).mapSuccess { }
return runConda(
condaPath, args, envIdentity
) { PyResult.success(Unit) }
}
suspend fun listEnvs(condaPath: Path): PyResult<CondaEnvInfo> {
val args = listOf("env", "list", "--json")
val json = runConda(condaPath, args, null).getOr { return it }
return pyRunCatching {
CondaExecutionParser.parseListEnvironmentsOutput(json)
}
return runConda(
condaPath, args, null,
transformer = ZeroCodeJsonParserTransformer { CondaExecutionParser.parseListEnvironmentsOutput(it) }
)
}
suspend fun exportEnvironmentFile(condaPath: Path, envIdentity: PyCondaEnvIdentity): PyResult<String> {
return runConda(condaPath, listOf("env", "export") + listOf("--no-builds"), envIdentity)
return runConda(
condaPath, listOf("env", "export") + listOf("--no-builds"), envIdentity,
transformer = ZeroCodeStdoutTransformer
)
}
suspend fun listPackages(condaPath: Path, envIdentity: PyCondaEnvIdentity): PyResult<List<PythonPackage>> {
return runConda(condaPath, listOf("list", "--json"), envIdentity).mapSuccess {
CondaExecutionParser.parseCondaPackageList(it)
}
return runConda(
condaPath, listOf("list", "--json"), envIdentity,
transformer = ZeroCodeJsonParserTransformer { CondaExecutionParser.parseCondaPackageList(it) }
)
}
suspend fun installPackages(condaPath: Path, envIdentity: PyCondaEnvIdentity, packages: List<String>, options: List<String>): PyResult<Unit> {
return runConda(condaPath, listOf("install") + packages + listOf("-y") + options, envIdentity).mapSuccess { }
return runConda(
condaPath, listOf("install") + packages + listOf("-y") + options, envIdentity
) { PyResult.success(Unit) }
}
suspend fun uninstallPackages(condaPath: Path, envIdentity: PyCondaEnvIdentity, packages: List<String>): PyResult<Unit> {
return runConda(condaPath, listOf("uninstall") + packages + "-y", envIdentity).mapSuccess { }
return runConda(
condaPath, listOf("uninstall") + packages + "-y", envIdentity
) { PyResult.success(Unit) }
}
suspend fun listOutdatedPackages(condaPath: Path, envIdentity: PyCondaEnvIdentity): PyResult<List<PythonOutdatedPackage>> {
val jsonPyResult = runConda(condaPath, listOf("update", "--dry-run", "--all", "--json"), envIdentity).getOr {
return it
}
return pyRunCatching {
CondaExecutionParser.parseOutdatedOutputs(jsonPyResult)
}
return runConda(
condaPath, listOf("update", "--dry-run", "--all", "--json"), envIdentity,
transformer = ZeroCodeJsonParserTransformer { CondaExecutionParser.parseOutdatedOutputs(it) }
)
}
private suspend fun runConda(condaPath: Path, args: List<String>, condaEnvIdentity: PyCondaEnvIdentity?, timeout: Duration = 15.minutes): PyResult<String> {
private suspend fun <T> runConda(
condaPath: Path,
args: List<String>,
condaEnvIdentity: PyCondaEnvIdentity?,
timeout: Duration = 15.minutes,
transformer: ProcessOutputTransformer<T>,
): PyResult<T> {
val condaEnv = when (condaEnvIdentity) {
is PyCondaEnvIdentity.UnnamedEnv -> {
if (condaEnvIdentity.isBase)
@@ -98,7 +120,7 @@ object CondaExecutor {
}
val runArgs = (args + condaEnv).toTypedArray()
return runExecutableWithProgress(condaPath, null, timeout, env = envs, *runArgs)
return runExecutableWithProgress(condaPath, null, timeout, env = envs, *runArgs, transformer = transformer)
}
private fun getFixedEnvs(condaPath: Path): PyResult<Map<String, String>> {