Python: Introduce API to get process from SDK via ExecService

Look `src/com/jetbrains/python/sdk/sdkExecApi.kt` for method that executes SDK and returns a process.

See `PySdkOnTargetTest` as a usage example.


Merge-request: IJ-MR-171740
Merged-by: Ilya Kazakevich <ilya.kazakevich@jetbrains.com>

GitOrigin-RevId: a7e9b6995eba7ae1fa71019b370acca2d98738d2
This commit is contained in:
Ilya Kazakevich
2025-08-08 19:08:05 +00:00
committed by intellij-monorepo-bot
parent b03577e03d
commit ec7562bf07
13 changed files with 154 additions and 42 deletions
@@ -38,17 +38,19 @@ sealed interface Exe {
}
}
typealias ExecError = ExecErrorImpl<*>
/**
* External process error.
*/
class ExecError(
class ExecErrorImpl<T : ExecErrorReason>(
val exe: Exe,
/**
* I.e ['-v']
*/
val args: Array<out String>,
val errorReason: ExecErrorReason,
val errorReason: T,
/**
* optional message to be displayed to the user: Why did we run this process. I.e "running pip to install package".
*/
@@ -29,7 +29,7 @@ class PyExecutionException private constructor(
additionalMessageToUser: @NlsContexts.DialogMessage String?,
command: String,
args: List<String>,
): PyExecutionException = PyExecutionException(ExecError(Exe.fromString(command), args.toTypedArray(), ExecErrorReason.Timeout, additionalMessageToUser))
): PyExecutionException = PyExecutionException(ExecErrorImpl(Exe.fromString(command), args.toTypedArray(), ExecErrorReason.Timeout, additionalMessageToUser))
}
@@ -63,7 +63,7 @@ class PyExecutionException private constructor(
args: List<String>,
fixes: List<PyExecutionFix> = listOf<PyExecutionFix>(),
) : this(
pyError = ExecError(Exe.fromString(command), args.toTypedArray(), ExecErrorReason.CantStart(null, startException.localizedMessage), additionalMessage),
pyError = ExecErrorImpl(Exe.fromString(command), args.toTypedArray(), ExecErrorReason.CantStart(null, startException.localizedMessage), additionalMessage),
fixes = fixes,
ioException = startException)
@@ -82,7 +82,7 @@ class PyExecutionException private constructor(
output: ProcessOutput,
fixes: List<PyExecutionFix> = listOf<PyExecutionFix>(),
) : this(
pyError = ExecError(Exe.fromString(command), args.toTypedArray(), output.asExecutionFailed()),
pyError = ExecErrorImpl(Exe.fromString(command), args.toTypedArray(), output.asExecutionFailed()),
fixes = fixes)
/**
@@ -5,7 +5,8 @@ import com.intellij.platform.eel.channels.EelSendChannel
import com.intellij.platform.eel.provider.utils.EelProcessExecutionResult
import com.intellij.python.community.execService.impl.ProcessSemiInteractiveHandlerImpl
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.errorProcessing.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.CheckReturnValue
@@ -40,6 +41,18 @@ interface ExecService {
options: ExecOptions = ExecOptions(),
processInteractiveHandler: ProcessInteractiveHandler<T>,
): PyResult<T>
/**
* Execute process from [binary] with [args]. It is up to you to manage its lifecycle.
* When bound to [scopeToBind] process will be destroyed once [scopeToBind] gets cancelled.
*/
@CheckReturnValue
suspend fun executeGetProcess(
binary: BinaryToExec,
args: Args = Args(),
scopeToBind: CoroutineScope? = null,
options: ExecGetProcessOptions = ExecGetProcessOptions(),
): Result<Process, ExecuteGetProcessError<*>>
}
/**
@@ -72,3 +85,17 @@ typealias ProcessSemiInteractiveFun<T> = suspend (EelSendChannel, Deferred<EelPr
* So, you can only *write* something to process.
*/
fun <T> processSemiInteractiveHandler(pyProcessListener: PyProcessListener? = null, code: ProcessSemiInteractiveFun<T>): ProcessInteractiveHandler<T> = ProcessSemiInteractiveHandlerImpl(pyProcessListener, code)
sealed class ExecuteGetProcessError<T : PyError>(val pyError: T) {
/**
* Failed to create environment for process (i.e. failed to start docker)
*/
class EnvironmentError internal constructor(pyError: MessageError) : ExecuteGetProcessError<MessageError>(pyError)
/**
* Process couldn't be started
*/
class CanStart internal constructor(pyError: ExecErrorImpl<ExecErrorReason.CantStart>) : ExecuteGetProcessError<ExecErrorImpl<ExecErrorReason.CantStart>>(pyError)
}
@@ -169,12 +169,27 @@ object ZeroCodeStdoutTransformer : ProcessOutputTransformer<String> {
* @property[env] Environment variables to be applied with the process run
* @property[timeout] Process gets killed after this timeout
* @property[processDescription] optional description to be displayed to user
* [tty] Much like [com.intellij.platform.eel.EelExecApi.Pty]
*/
data class ExecOptions(
val env: Map<String, String> = emptyMap(),
val processDescription: @Nls String? = null,
override val env: Map<String, String> = emptyMap(),
override val processDescription: @Nls String? = null,
val timeout: Duration = 5.minutes,
)
override val tty: TtySize? = null,
) : ExecOptionsBase
/**
* Options for [ExecService.executeGetProcess]
* See [ExecOptions]
*/
data class ExecGetProcessOptions(
override val env: Map<String, String> = emptyMap(),
override val processDescription: @Nls String? = null,
override val tty: TtySize? = null,
) : ExecOptionsBase
data class TtySize(val rows: UShort, val cols: UShort)
/**
* See [Args.addLocalFile]
@@ -1,14 +1,18 @@
// 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.impl
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.python.community.execService.*
import com.intellij.python.community.execService.impl.processLaunchers.*
import com.intellij.python.community.execService.impl.processLaunchers.LaunchRequest
import com.intellij.python.community.execService.impl.processLaunchers.ProcessLauncher
import com.intellij.python.community.execService.impl.processLaunchers.createProcessLauncherOnEel
import com.intellij.python.community.execService.impl.processLaunchers.createProcessLauncherOnTarget
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.ExecError
import com.jetbrains.python.errorProcessing.ExecErrorReason
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.errorProcessing.failure
import com.jetbrains.python.errorProcessing.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withTimeout
@@ -17,13 +21,34 @@ import org.jetbrains.annotations.Nls
internal object ExecServiceImpl : ExecService {
override suspend fun <T> executeAdvanced(binary: BinaryToExec, args: Args, options: ExecOptions, processInteractiveHandler: ProcessInteractiveHandler<T>): PyResult<T> {
override suspend fun executeGetProcess(binary: BinaryToExec, args: Args, scopeToBind: CoroutineScope?, options: ExecGetProcessOptions): Result<Process, ExecuteGetProcessError<*>> {
val launcher = create(binary, args, options, scopeToBind).getOr { return it }
val process = launcher.start().getOr {
val createExecError = launcher.createExecError(options.processDescription ?: "", it.error).error
return Result.failure(ExecuteGetProcessError.CanStart(createExecError))
}
return Result.success(process)
}
return coroutineScope {
val request = LaunchRequest(this, args, options.env)
val processLauncher: ProcessLauncher = when (binary) {
private suspend fun create(binary: BinaryToExec, args: Args, options: ExecOptionsBase, scopeToBind: CoroutineScope? = null): Result<ProcessLauncher, ExecuteGetProcessError.EnvironmentError> {
val scope = scopeToBind ?: ApplicationManager.getApplication().service<MyService>().scope
val request = LaunchRequest(scope, args, options.env, options.tty)
return Result.success(
when (binary) {
is BinOnEel -> createProcessLauncherOnEel(binary, request)
is BinOnTarget -> createProcessLauncherOnTarget(binary, request).getOr { return@coroutineScope it }
is BinOnTarget -> createProcessLauncherOnTarget(binary, request).getOr {
options.processDescription?.let { message ->
it.error.pyError.addMessage(message) // TODO: 18n
}
return it
}
})
}
override suspend fun <T> executeAdvanced(binary: BinaryToExec, args: Args, options: ExecOptions, processInteractiveHandler: ProcessInteractiveHandler<T>): PyResult<T> {
return coroutineScope {
val processLauncher = create(binary, args, options, this).getOr {
return@coroutineScope it.asPyError()
}
val description = options.processDescription
@@ -66,8 +91,8 @@ internal object ExecServiceImpl : ExecService {
}
}
private fun ProcessLauncher.createExecError(messageToUser: @Nls String, errorReason: ExecErrorReason): Result.Failure<ExecError> =
ExecError(
private fun <T : ExecErrorReason> ProcessLauncher.createExecError(messageToUser: @Nls String, errorReason: T): Result.Failure<ExecErrorImpl<T>> =
ExecErrorImpl(
exe = exeForError,
args = args.toTypedArray(),
additionalMessageToUser = messageToUser,
@@ -75,7 +100,12 @@ private fun ProcessLauncher.createExecError(messageToUser: @Nls String, errorRea
).logAndFail()
private fun ExecError.logAndFail(): Result.Failure<ExecError> {
private fun <T : ExecErrorReason> ExecErrorImpl<T>.logAndFail(): Result.Failure<ExecErrorImpl<T>> {
fileLogger().warn(message)
return failure(this)
}
@Service
private class MyService(val scope: CoroutineScope)
private fun Result.Failure<ExecuteGetProcessError<*>>.asPyError(): Result.Failure<PyError> = PyResult.failure(this.error.pyError)
@@ -4,6 +4,7 @@ package com.intellij.python.community.execService.impl.processLaunchers
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.platform.eel.provider.utils.ProcessFunctions
import com.intellij.python.community.execService.Args
import com.intellij.python.community.execService.TtySize
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.Exe
import com.jetbrains.python.errorProcessing.ExecErrorReason
@@ -31,4 +32,5 @@ internal data class LaunchRequest(
val scopeToBind: CoroutineScope,
val args: Args,
val env: Map<String, String>,
)
val usePty: TtySize?
)
@@ -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.community.execService.impl.processLaunchers
import com.intellij.platform.eel.EelExecApi
import com.intellij.platform.eel.EelProcess
import com.intellij.platform.eel.ExecuteProcessException
import com.intellij.platform.eel.path.EelPath
@@ -9,6 +10,7 @@ import com.intellij.platform.eel.provider.utils.EelPathUtils
import com.intellij.platform.eel.provider.utils.ProcessFunctions
import com.intellij.platform.eel.spawnProcess
import com.intellij.python.community.execService.BinOnEel
import com.intellij.python.community.execService.TtySize
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.Exe
import com.jetbrains.python.errorProcessing.ExecErrorReason
@@ -28,7 +30,7 @@ internal suspend fun createProcessLauncherOnEel(binOnEel: BinOnEel, launchReques
return ProcessLauncher(
exeForError = Exe.OnEel(exePath),
args = args,
processCommands = EelProcessCommands(launchRequest.scopeToBind, binOnEel, exePath, args, launchRequest.env)
processCommands = EelProcessCommands(launchRequest.scopeToBind, binOnEel, exePath, args, launchRequest.env, launchRequest.usePty)
)
}
@@ -38,6 +40,7 @@ private class EelProcessCommands(
private val path: EelPath,
private val args: List<String>,
private val env: Map<String, String>,
private val tty: TtySize?,
) : ProcessCommands {
private var eelProcess: EelProcess? = null
@@ -56,6 +59,7 @@ private class EelProcessCommands(
.args(args)
.env(env)
.workingDirectory(workDir?.asEelPath())
.interactionOptions(if (tty != null) EelExecApi.Pty(tty.cols.toInt(), tty.rows.toInt()) else null)
.eelIt()
this.eelProcess = eelProcess
return Result.success(eelProcess.convertToJavaProcess())
@@ -4,18 +4,20 @@
package com.intellij.python.community.execService.impl.processLaunchers
import com.intellij.execution.ExecutionException
import com.intellij.execution.process.LocalPtyOptions
import com.intellij.execution.target.*
import com.intellij.execution.target.local.LocalTargetEnvironmentRequest
import com.intellij.execution.target.local.LocalTargetPtyOptions
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.openapi.project.ProjectManager
import com.intellij.platform.eel.provider.utils.ProcessFunctions
import com.intellij.platform.eel.provider.utils.bindProcessToScopeImpl
import com.intellij.python.community.execService.BinOnTarget
import com.intellij.python.community.execService.ExecuteGetProcessError
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.Exe
import com.jetbrains.python.errorProcessing.ExecErrorReason
import com.jetbrains.python.errorProcessing.MessageError
import com.jetbrains.python.errorProcessing.PyResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -25,7 +27,7 @@ import kotlin.time.Duration.Companion.milliseconds
private val logger = fileLogger()
internal suspend fun createProcessLauncherOnTarget(binOnTarget: BinOnTarget, launchRequest: LaunchRequest): PyResult<ProcessLauncher> = withContext(Dispatchers.IO) {
internal suspend fun createProcessLauncherOnTarget(binOnTarget: BinOnTarget, launchRequest: LaunchRequest): Result<ProcessLauncher, ExecuteGetProcessError.EnvironmentError> = withContext(Dispatchers.IO) {
val target = binOnTarget.target
val request = if (target != null) {
@@ -44,19 +46,29 @@ internal suspend fun createProcessLauncherOnTarget(binOnTarget: BinOnTarget, lau
}
catch (e: ExecutionException) {
fileLogger().warn("Failed to start $target", e) // TODO: i18n
return@withContext Result.failure(MessageError("Failed to start environment due to ${e.localizedMessage}"))
return@withContext Result.failure(ExecuteGetProcessError.EnvironmentError(MessageError("Failed to start environment due to ${e.localizedMessage}")))
}
val args = launchRequest.args.getArgs { localFile ->
targetEnv.getTargetPaths(localFile.pathString).first()
}
val exePath: FullPathOnTarget
val cmdLine = TargetedCommandLineBuilder(request).also {
binOnTarget.configureTargetCmdLine(it)
val cmdLine = TargetedCommandLineBuilder(request).also {commandLineBuilder ->
binOnTarget.configureTargetCmdLine(commandLineBuilder)
// exe path is always fixed (pre-presolved) promise. It can't be obtained directly because of Targets API limitation
exePath = it.exePath.localValue.blockingGet(1000) ?: error("Exe path not set: $binOnTarget is broken")
it.addParameters(args)
exePath = commandLineBuilder.exePath.localValue.blockingGet(1000) ?: error("Exe path not set: $binOnTarget is broken")
launchRequest.usePty?.let {
val ptyOptions = LocalPtyOptions
.defaults()
.builder()
.initialRows(it.rows.toInt())
.initialColumns(it.cols.toInt())
.build()
commandLineBuilder.ptyOptions = LocalTargetPtyOptions(ptyOptions)
}
commandLineBuilder.addParameters(args)
for ((k, v) in launchRequest.env) {
it.addEnvironmentVariable(k, v)
commandLineBuilder.addEnvironmentVariable(k, v)
}
}.build()
return@withContext Result.success(ProcessLauncher(exeForError = Exe.OnTarget(exePath), args = args, processCommands = TargetProcessCommands(launchRequest.scopeToBind, exePath, targetEnv, cmdLine)))
@@ -0,0 +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.community.execService
import org.jetbrains.annotations.Nls
internal sealed interface ExecOptionsBase {
val env: Map<String, String>
val processDescription: @Nls String?
val tty: TtySize?
}
@@ -4,10 +4,7 @@ package com.jetbrains.python.packaging
import com.intellij.execution.process.ProcessOutput
import com.intellij.platform.eel.provider.utils.stderrString
import com.intellij.platform.eel.provider.utils.stdoutString
import com.jetbrains.python.errorProcessing.Exe
import com.jetbrains.python.errorProcessing.ExecError
import com.jetbrains.python.errorProcessing.ExecErrorReason
import com.jetbrains.python.errorProcessing.MessageError
import com.jetbrains.python.errorProcessing.*
import java.io.IOException
/**
@@ -21,7 +18,7 @@ internal fun PyExecutionException.copyWith(newCommand: String, newArgs: List<Str
PyExecutionException(IOException(reason.cantExecProcessError), err.message, newCommand, newArgs, fixes)
}
ExecErrorReason.Timeout -> {
PyExecutionException(ExecError(Exe.fromString(newCommand), newArgs.toTypedArray(), ExecErrorReason.Timeout, err.message))
PyExecutionException(ExecErrorImpl(Exe.fromString(newCommand), newArgs.toTypedArray(), ExecErrorReason.Timeout, err.message))
}
is ExecErrorReason.UnexpectedProcessTermination -> {
val output = ProcessOutput(reason.stdoutString, reason.stderrString, reason.exitCode, false, false)
@@ -15,7 +15,7 @@ import com.intellij.util.concurrency.annotations.RequiresReadLock;
import com.intellij.util.net.HttpConfigurable;
import com.jetbrains.python.PyPsiPackageUtil;
import com.jetbrains.python.PySdkBundle;
import com.jetbrains.python.errorProcessing.ExecError;
import com.jetbrains.python.errorProcessing.ExecErrorImpl;
import com.jetbrains.python.errorProcessing.ExecErrorReason;
import com.jetbrains.python.packaging.common.PythonPackage;
import com.jetbrains.python.packaging.pip.PipParseUtils;
@@ -134,7 +134,7 @@ public abstract class PyPackageManagerImplBase extends PyPackageManager {
}
catch (PyExecutionException e) {
var pyError = e.getPyError();
if (pyError instanceof ExecError error) {
if (pyError instanceof ExecErrorImpl<?> error) {
var errorReason = error.getErrorReason();
if (errorReason instanceof ExecErrorReason.UnexpectedProcessTermination unexpectedProcessTermination) {
int exitCode = unexpectedProcessTermination.getExitCode();
@@ -20,7 +20,7 @@ import com.intellij.webcore.packaging.RepoPackage;
import com.jetbrains.python.PyBundle;
import com.jetbrains.python.PySdkBundle;
import com.jetbrains.python.errorProcessing.Exe;
import com.jetbrains.python.errorProcessing.ExecError;
import com.jetbrains.python.errorProcessing.ExecErrorImpl;
import com.jetbrains.python.errorProcessing.ExecErrorReason;
import com.jetbrains.python.packaging.*;
import com.jetbrains.python.packaging.PyPIPackageUtil.PackageDetails;
@@ -320,7 +320,7 @@ public class PyPackageManagementService extends PackageManagementServiceEx {
@Nullable Sdk sdk,
@Nullable String packageName) {
if (e instanceof PyExecutionException pyExecEx &&
pyExecEx.getPyError() instanceof ExecError execError &&
pyExecEx.getPyError() instanceof ExecErrorImpl<?> execError &&
execError.getErrorReason() instanceof ExecErrorReason.UnexpectedProcessTermination execFailed) {
var stdout = getStdoutString(execFailed);
var stderr = getStderrString(execFailed);
@@ -343,7 +343,7 @@ public class PyPackageManagementService extends PackageManagementServiceEx {
private static @Nullable @DetailedDescription String findErrorSolution(@NotNull PyExecutionException executionException,
@Nullable String cause,
@Nullable Sdk sdk) {
if (executionException.getPyError() instanceof ExecError e) {
if (executionException.getPyError() instanceof ExecErrorImpl<?> e) {
if (cause != null) {
if (StringUtil.containsIgnoreCase(cause, "SyntaxError")) {
@@ -5,8 +5,10 @@ import com.intellij.openapi.projectRoots.Sdk
import com.intellij.python.community.execService.*
import com.intellij.python.community.execService.python.HelperName
import com.intellij.python.community.execService.python.addHelper
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.target.PyTargetAwareAdditionalData
import kotlinx.coroutines.CoroutineScope
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.CheckReturnValue
@@ -48,6 +50,17 @@ suspend fun ExecService.executeHelper(
): PyResult<String> =
execGetStdout(sdk, Args().addHelper(helper).addArgs(helperArgs), options, procListener)
// See function it calls for more info
@ApiStatus.Internal
@CheckReturnValue
suspend fun ExecService.executeGetProcess(
sdk: Sdk,
args: Args = Args(),
scopeToBind: CoroutineScope? = null,
options: ExecGetProcessOptions = ExecGetProcessOptions(),
): Result<Process, ExecuteGetProcessError<*>> = executeGetProcess(sdk.asBinToExecute(), args, scopeToBind, options)
/**
* Converts SDK to [BinOnTarget] to be used by [ExecService]
*/