Python: refactor ExecService to get rid of ProcessOutput and better support interactive execution.

1. `ProcessOutput` is a legacy thing with some redundant flags: replaced with modern `EelProcessExecutionResult`.

2. There was a bug in `ProcessInteractiveHandler`: one could fetch all data from stdout, and we then did that again to get a result. That leads to an empty result. It is now fixed, see `executeInteractive` doc.

GitOrigin-RevId: 7fa57f8110a202a32a3531ff6507d42a270075a3
This commit is contained in:
Ilya.Kazakevich
2025-05-08 23:32:15 +00:00
committed by intellij-monorepo-bot
parent 067fef4544
commit 11c31cfde7
14 changed files with 200 additions and 73 deletions
@@ -1,9 +1,12 @@
// 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.hatch.cli
import com.intellij.execution.process.ProcessOutput
import com.intellij.openapi.util.NlsSafe
import com.intellij.platform.eel.getOr
import com.intellij.platform.eel.provider.utils.EelProcessExecutionResultInfo
import com.intellij.platform.eel.provider.utils.sendWholeText
import com.intellij.platform.eel.provider.utils.stderrString
import com.intellij.platform.eel.provider.utils.stdoutString
import com.intellij.python.community.execService.ProcessOutputTransformer
import com.intellij.python.hatch.PyHatchBundle
import com.intellij.python.hatch.runtime.HatchConstants
@@ -22,8 +25,8 @@ private suspend fun <T> HatchRuntime.executeAndHandleErrors(vararg arguments: St
val errorHandlerTransformer: ProcessOutputTransformer<T> = { output ->
when {
output.exitCode !in 0..1 -> Result.failure(null)
output.exitCode == 1 && output.stdout.substringBefore('\n').contains("Traceback (most recent call last)") -> {
val hatchErrorDescription = output.stdout.split('\n').lastOrNull { it.isNotEmpty() } ?: ""
output.exitCode == 1 && output.stdoutString.substringBefore('\n').contains("Traceback (most recent call last)") -> {
val hatchErrorDescription = output.stdoutString.split('\n').lastOrNull { it.isNotEmpty() } ?: ""
Result.failure(hatchErrorDescription)
}
else -> transformer.invoke(output)
@@ -36,7 +39,7 @@ private suspend fun <T> HatchRuntime.executeAndHandleErrors(vararg arguments: St
private suspend fun <T> HatchRuntime.executeAndMatch(
vararg arguments: String,
expectedOutput: Regex,
outputContentSupplier: (ProcessOutput) -> String = ProcessOutput::getStdout,
outputContentSupplier: (EelProcessExecutionResultInfo) -> String = { it.stdoutString },
transformer: (MatchResult) -> Result<T, @NlsSafe String?>,
): Result<T, ExecError> {
return this.executeAndHandleErrors(*arguments) { processOutput ->
@@ -120,9 +123,9 @@ class HatchCli(private val runtime: HatchRuntime) {
true to projectName,
(location != null) to location,
).makeOptions()
return runtime.executeInteractive("new", *options) { eelProcess ->
return runtime.executeInteractive("new", *options) { eelProcess, _ ->
if (initExistingProject) {
eelProcess.stdin.sendWholeText("$projectName\n")
eelProcess.sendWholeText("$projectName\n").getOr { return@executeInteractive Result.failure("Failed to write to process: ${it.error.localizedMessage}") }
}
Result.success("Created")
}
@@ -151,8 +154,8 @@ class HatchCli(private val runtime: HatchRuntime) {
return envRuntime.executeAndHandleErrors("run", *command) { output ->
if (output.exitCode != 0) return@executeAndHandleErrors Result.failure(null)
val scenario = output.stderr.trim()
val installDetailsContent = output.stdout.replace("", "").trim()
val scenario = output.stderrString.trim()
val installDetailsContent = output.stdoutString.replace("", "").trim()
val info = installDetailsContent.lines().drop(1).dropLast(2).joinToString("\n")
Result.success("$scenario\n$info")
@@ -177,7 +180,7 @@ class HatchCli(private val runtime: HatchRuntime) {
suspend fun status(): Result<HatchStatus, ExecError> {
val expectedOutput = """^\[Project] - (.*)\n\[Location] - (.*)\n\[Config] - (.*)\n$""".toRegex()
return runtime.executeAndMatch("status", expectedOutput = expectedOutput, outputContentSupplier = { it.stderr }) { matchResult ->
return runtime.executeAndMatch("status", expectedOutput = expectedOutput, outputContentSupplier = { it.stderrString }) { matchResult ->
val (project, location, config) = matchResult.destructured
try {
Result.success(HatchStatus(project, Path.of(location), Path.of(config)))
@@ -200,7 +203,7 @@ class HatchCli(private val runtime: HatchRuntime) {
*/
suspend fun getVersion(): Result<Version, ExecError> {
return runtime.executeAndHandleErrors("version") { processOutput ->
val output = processOutput.takeIf { it.exitCode == 0 }?.stdout?.trim()
val output = processOutput.takeIf { it.exitCode == 0 }?.stdoutString?.trim()
?: return@executeAndHandleErrors Result.failure(null)
try {
Result.success(Version.parse(output))
@@ -219,7 +222,7 @@ class HatchCli(private val runtime: HatchRuntime) {
suspend fun setVersion(desiredVersion: String): PyResult<Pair<Version, Version>> {
val expectedOutput = """^Old: (.*)\nNew: (.*)\n$""".toRegex()
return runtime.executeAndMatch("version", desiredVersion, expectedOutput = expectedOutput, outputContentSupplier = { it.stderr }) { matchResult ->
return runtime.executeAndMatch("version", desiredVersion, expectedOutput = expectedOutput, outputContentSupplier = { it.stderrString }) { matchResult ->
val (oldVersion, newVersion) = matchResult.destructured
try {
Result.success(Version.parse(oldVersion) to Version.parse(newVersion))
@@ -2,6 +2,8 @@
package com.intellij.python.hatch.cli
import com.intellij.openapi.util.NlsSafe
import com.intellij.platform.eel.provider.utils.stderrString
import com.intellij.platform.eel.provider.utils.stdoutString
import com.intellij.python.hatch.runtime.HatchRuntime
import com.jetbrains.python.PythonHomePath
import com.jetbrains.python.Result
@@ -132,9 +134,9 @@ class HatchEnv(runtime: HatchRuntime) : HatchCommand("env", runtime) {
return executeAndHandleErrors("create", *arguments) {
val actualEnvName = envName ?: DEFAULT_ENV_NAME
when {
it.exitCode == 0 && it.stderr.startsWith("Creating environment") -> Result.success(CreateResult.Created)
it.exitCode == 0 && it.stderrString.startsWith("Creating environment") -> Result.success(CreateResult.Created)
it.exitCode == 0 -> Result.success(CreateResult.AlreadyExists)
it.stderr.startsWith("Environment `$actualEnvName` is not defined by project config") -> Result.success(CreateResult.NotDefinedInConfig)
it.stderrString.startsWith("Environment `$actualEnvName` is not defined by project config") -> Result.success(CreateResult.NotDefinedInConfig)
else -> Result.failure(null)
}
}
@@ -149,9 +151,9 @@ class HatchEnv(runtime: HatchRuntime) : HatchCommand("env", runtime) {
val arguments = if (envName == null) emptyArray() else arrayOf(envName)
return executeAndHandleErrors("find", *arguments) {
when (it.exitCode) {
0 -> Result.success(Path.of(it.stdout.trim()))
0 -> Result.success(Path.of(it.stdoutString.trim()))
else -> {
if (it.stderr.startsWith("Environment `${envName ?: DEFAULT_ENV_NAME}` is not defined by project config")) {
if (it.stderrString.startsWith("Environment `${envName ?: DEFAULT_ENV_NAME}` is not defined by project config")) {
Result.success(null)
}
else {
@@ -185,10 +187,10 @@ class HatchEnv(runtime: HatchRuntime) : HatchCommand("env", runtime) {
return executeAndHandleErrors("remove", *arguments) {
val actualEnvName = envName ?: DEFAULT_ENV_NAME
when {
it.exitCode == 0 && it.stderr.startsWith("Removing environment") -> Result.success(RemoveResult.Removed)
it.exitCode == 0 && it.stderr.isBlank() -> Result.success(RemoveResult.NotExists)
it.stderr.startsWith("Environment `$actualEnvName` is not defined by project config") -> Result.success(RemoveResult.NotDefinedInConfig)
it.stderr.startsWith("Cannot remove active environment") -> Result.success(RemoveResult.CantRemoveActiveEnvironment)
it.exitCode == 0 && it.stderrString.startsWith("Removing environment") -> Result.success(RemoveResult.Removed)
it.exitCode == 0 && it.stderrString.isBlank() -> Result.success(RemoveResult.NotExists)
it.stderrString.startsWith("Environment `$actualEnvName` is not defined by project config") -> Result.success(RemoveResult.NotDefinedInConfig)
it.stderrString.startsWith("Cannot remove active environment") -> Result.success(RemoveResult.CantRemoveActiveEnvironment)
else -> Result.failure(null)
}
}
@@ -204,7 +206,7 @@ class HatchEnv(runtime: HatchRuntime) : HatchCommand("env", runtime) {
*/
suspend fun showWithDetails(vararg envs: String): Result<HatchDetailedEnvironments, ExecError> {
return executeAndHandleErrors("show", "--json", *envs) { processOutput ->
val output = processOutput.takeIf { it.exitCode == 0 }?.stdout
val output = processOutput.takeIf { it.exitCode == 0 }?.stdoutString
?: return@executeAndHandleErrors Result.failure(null)
val json = Json { ignoreUnknownKeys = true }
@@ -1,6 +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.intellij.python.hatch.cli
import com.intellij.platform.eel.provider.utils.stdoutString
import com.intellij.python.hatch.runtime.HatchRuntime
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.ExecError
@@ -63,7 +64,7 @@ class HatchProject(runtime: HatchRuntime) : HatchCommand("project", runtime) {
*/
suspend fun metadata(): Result<Metadata, ExecError> {
return executeAndHandleErrors("metadata") { processOutput ->
val output = processOutput.takeIf { it.exitCode == 0 }?.stdout
val output = processOutput.takeIf { it.exitCode == 0 }?.stdoutString
?: return@executeAndHandleErrors Result.failure(null)
val json = Json { ignoreUnknownKeys = true }
@@ -1,8 +1,10 @@
// 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.hatch.cli
import com.intellij.execution.process.ProcessOutput
import com.intellij.openapi.util.io.NioFiles
import com.intellij.platform.eel.provider.utils.EelProcessExecutionResultInfo
import com.intellij.platform.eel.provider.utils.stderrString
import com.intellij.platform.eel.provider.utils.stdoutString
import com.intellij.python.hatch.cli.HatchPython.PythonInstallResponse.AbortReason
import com.intellij.python.hatch.runtime.HatchRuntime
import com.jetbrains.python.Result
@@ -29,10 +31,10 @@ class HatchPython(runtime: HatchRuntime) : HatchCommand("python", runtime) {
return executeAndHandleErrors("find", *options, name) { output ->
when {
output.exitCode == 1 && output.stderr.contains("Distribution not installed: $name") -> Result.success(null)
output.exitCode == 1 && output.stderrString.contains("Distribution not installed: $name") -> Result.success(null)
output.exitCode != 0 -> Result.failure(null)
else -> {
val path = NioFiles.toPath(output.stdout.trim())
val path = NioFiles.toPath(output.stdoutString.trim())
path?.let { Result.success(it) } ?: Result.failure(null)
}
}
@@ -78,8 +80,8 @@ class HatchPython(runtime: HatchRuntime) : HatchCommand("python", runtime) {
* app.display(public_directory)
* ```
*/
fun parsePythonInstallCommandOutput(processOutput: ProcessOutput): PythonInstallResponse {
val output = processOutput.stderr.replace("\r\n", "\n")
fun parsePythonInstallCommandOutput(processOutput: EelProcessExecutionResultInfo): PythonInstallResponse {
val output = processOutput.stderrString.replace("\r\n", "\n")
val abort = AbortReason.parse(output)?.let { PythonInstallResponse.Abort(it, output) }
@@ -144,7 +146,7 @@ class HatchPython(runtime: HatchRuntime) : HatchCommand("python", runtime) {
*/
suspend fun remove(vararg names: String = ALL_NAMES, dir: String? = null): Result<PythonRemoveResponse, ExecError> {
return executeAndHandleErrors("remove", *buildDirOption(dir), *names) { processOutput ->
val output = processOutput.stderr
val output = processOutput.stderrString
val notInstalledRegex = Regex("""^Distribution is not installed: (.*)$""", RegexOption.MULTILINE)
val notInstalled = notInstalledRegex.findAll(output).map { it.destructured.component1() }.toList()
@@ -1,6 +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.intellij.python.hatch.cli
import com.intellij.platform.eel.provider.utils.stdoutString
import com.intellij.python.hatch.PyHatchBundle
import com.intellij.python.hatch.runtime.HatchRuntime
import com.intellij.util.Url
@@ -18,7 +19,7 @@ class HatchSelf(runtime: HatchRuntime) : HatchCommand("self", runtime) {
*/
suspend fun report(): Result<Url, ExecError> {
return executeAndHandleErrors("report", "--no-open") { processOutput ->
val output = processOutput.takeIf { it.exitCode == 0 }?.stdout?.trim()
val output = processOutput.takeIf { it.exitCode == 0 }?.stdoutString?.trim()
?: return@executeAndHandleErrors Result.failure(null)
val url = Urls.parseEncoded(output)
if (url != null) {
@@ -2,10 +2,7 @@ package com.intellij.python.hatch.runtime
import com.intellij.platform.eel.EelApi
import com.intellij.platform.eel.provider.localEel
import com.intellij.python.community.execService.EelProcessInteractiveHandler
import com.intellij.python.community.execService.ExecOptions
import com.intellij.python.community.execService.ExecService
import com.intellij.python.community.execService.ProcessOutputTransformer
import com.intellij.python.community.execService.*
import com.intellij.python.community.execService.WhatToExec.Binary
import com.intellij.python.hatch.*
import com.intellij.python.hatch.cli.HatchCli
@@ -62,8 +59,8 @@ class HatchRuntime(
return execService.execute(hatchBinary, arguments.toList(), execOptions, processOutputTransformer)
}
internal suspend fun <T> executeInteractive(vararg arguments: String, eelProcessInteractiveHandler: EelProcessInteractiveHandler<T>): Result<T, ExecError> {
return execService.executeInteractive(hatchBinary, arguments.toList(), execOptions, eelProcessInteractiveHandler)
internal suspend fun <T> executeInteractive(vararg arguments: String, processSemiInteractiveFun: ProcessSemiInteractiveFun<T>): Result<T, ExecError> {
return execService.executeInteractive(hatchBinary, arguments.toList(), execOptions, ProcessSemiInteractiveHandler(processSemiInteractiveFun))
}
internal suspend fun resolvePythonVirtualEnvironment(pythonHomePath: PythonHomePath): PyResult<PythonVirtualEnvironment> {