Python: Support Targets API in ExecService.

API changes:
Class `Args` is not used to provide arguments for commands. Previous implementation was incompatible with Targets.
Instead of `Path` we now accept `BinaryToExec` which is either Eel or Target based.

Impl:
`ExecServiceImpl` now delegates execution to `com.intellij.python.community.execService.impl.processLaunchers`: there are two functions (Eel and Targets)


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

GitOrigin-RevId: 4c4ac7023e6605caaacb1880d60faf145b4160bf
This commit is contained in:
Ilya Kazakevich
2025-08-06 16:10:47 +00:00
committed by intellij-monorepo-bot
parent bc59f691df
commit 8f83b52355
23 changed files with 473 additions and 215 deletions
@@ -8,10 +8,20 @@ import kotlin.coroutines.cancellation.CancellationException
@ApiStatus.Internal
data class ProcessFunctions(
class ProcessFunctions(
val waitForExit: suspend () -> Unit,
val killProcess: suspend () -> Unit,
)
private val killProcess: suspend () -> Unit,
) {
suspend fun killAndJoin(logger: Logger, processNameForDebug: String) {
withContext(NonCancellable) {
logger.warn("Sending kill to $processNameForDebug")
killProcess()
logger.warn("Kill send to $processNameForDebug, waiting")
waitForExit()
logger.warn("Process $processNameForDebug died")
}
}
}
/**
* This is an implementation detail to be reused by other parts of a system.
@@ -26,13 +36,7 @@ fun CoroutineScope.bindProcessToScopeImpl(
val context = CoroutineName("Waiting for process $processNameForDebug") + Dispatchers.IO
suspend fun killAndJoin() {
withContext(NonCancellable) {
logger.warn("Sending kill to $processNameForDebug")
processFunctions.killProcess()
logger.warn("Kill send to $processNameForDebug, waiting")
processFunctions.waitForExit()
logger.warn("Process $processNameForDebug died")
}
processFunctions.killAndJoin(logger, processNameForDebug)
}
if (!isActive) {
@@ -7,7 +7,6 @@ import com.intellij.python.community.execService.python.advancedApi.executeHelpe
import com.intellij.python.community.execService.python.advancedApi.executePythonAdvanced
import com.intellij.python.community.execService.python.advancedApi.validatePythonAndGetVersion
import com.intellij.python.community.interpreters.ValidInterpreter
import com.jetbrains.python.errorProcessing.PyExecResult
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.psi.LanguageLevel
import org.jetbrains.annotations.ApiStatus
@@ -19,11 +18,11 @@ import org.jetbrains.annotations.ApiStatus
*/
suspend fun <T> ExecService.executePythonAdvanced(
python: ValidInterpreter,
argsBuilder: suspend ArgsBuilder.() -> Unit = {},
args: Args,
options: ExecOptions = ExecOptions(),
processInteractiveHandler: ProcessInteractiveHandler<T>,
): PyResult<T> =
executePythonAdvanced(python.asExecutablePython, argsBuilder, options, processInteractiveHandler)
executePythonAdvanced(python.asExecutablePython, args, options, processInteractiveHandler)
/**
+2 -5
View File
@@ -2,10 +2,7 @@
package com.intellij.python.community.interpreters
import com.intellij.openapi.module.Module
import com.intellij.python.community.execService.ExecOptions
import com.intellij.python.community.execService.ExecService
import com.intellij.python.community.execService.PyProcessListener
import com.intellij.python.community.execService.ZeroCodeStdoutTransformer
import com.intellij.python.community.execService.*
import com.intellij.python.community.execService.impl.transformerToHandler
import com.intellij.python.community.execService.python.HelperName
import com.intellij.python.community.execService.python.advancedApi.executeHelperAdvanced
@@ -91,4 +88,4 @@ suspend fun ExecService.executeGetStdout(
options: ExecOptions = ExecOptions(),
procListener: PyProcessListener? = null,
): PyResult<String> =
executePythonAdvanced(python.asExecutablePython, { addArgs(*args.toTypedArray()) }, options, transformerToHandler(procListener, ZeroCodeStdoutTransformer))
executePythonAdvanced(python.asExecutablePython, Args(*args.toTypedArray()), options, transformerToHandler(procListener, ZeroCodeStdoutTransformer))
@@ -17,7 +17,7 @@ import kotlin.io.path.Path
/**
* Exe might sit on eel (new one) or on target (legacy)
*/
interface Exe {
sealed interface Exe {
companion object {
fun fromString(path: String): Exe {
try {
+4
View File
@@ -23,6 +23,8 @@ jvm_library(
"//platform/core-api:core",
"//platform/eel",
"//platform/util/progress",
"//platform/execution",
"//platform/projectModel-api:projectModel",
],
runtime_deps = [":community-execService_resources"]
)
@@ -53,6 +55,8 @@ jvm_library(
"@lib//:junit5Pioneer",
"//platform/testFramework/common",
"//platform/util/progress",
"//platform/execution",
"//platform/projectModel-api:projectModel",
],
runtime_deps = [":community-execService_resources"]
)
@@ -6,7 +6,6 @@ import com.intellij.python.community.execService.impl.transformerToHandler
import com.intellij.python.community.execService.python.HelperName
import com.intellij.python.community.execService.python.impl.validatePythonAndGetVersionImpl
import com.intellij.python.community.helpersLocator.PythonHelpersLocator
import com.jetbrains.python.errorProcessing.PyExecResult
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.psi.LanguageLevel
import org.jetbrains.annotations.ApiStatus
@@ -18,15 +17,15 @@ import org.jetbrains.annotations.ApiStatus
*/
suspend fun <T> ExecService.executePythonAdvanced(
python: ExecutablePython,
argsBuilder: suspend ArgsBuilder.() -> Unit = {},
args: Args,
options: ExecOptions = ExecOptions(),
processInteractiveHandler: ProcessInteractiveHandler<T>,
): PyExecResult<T> =
executeAdvanced(python.binary, {
addArgs(*python.args.toTypedArray())
argsBuilder()
): PyResult<T> =
executeAdvanced(
binary = BinOnEel(python.binary),
args = Args(*python.args.toTypedArray()) + args,
// TODO: Merge PATH
}, options.copy(env = options.env + python.env), processInteractiveHandler)
options = options.copy(env = options.env + python.env), processInteractiveHandler)
/**
@@ -39,11 +38,13 @@ suspend fun <T> ExecService.executeHelperAdvanced(
options: ExecOptions = ExecOptions(),
procListener: PyProcessListener? = null,
processOutputTransformer: ProcessOutputTransformer<T>,
): PyExecResult<T> = executePythonAdvanced(python, {
addLocalFile(PythonHelpersLocator.findPathInHelpers(helper))
addArgs(*args.toTypedArray())
}, options, transformerToHandler(procListener, processOutputTransformer))
): PyResult<T> = executePythonAdvanced(
python,
Args().apply {
addLocalFile(PythonHelpersLocator.findPathInHelpers(helper))
addArgs(*args.toTypedArray())
},
options, transformerToHandler(procListener, processOutputTransformer))
/**
* Ensures that this python is executable and returns its version. Error if python is broken.
@@ -5,6 +5,7 @@ import com.intellij.openapi.util.NlsSafe
import com.intellij.platform.eel.provider.utils.EelProcessExecutionResult
import com.intellij.platform.eel.provider.utils.stderrString
import com.intellij.platform.eel.provider.utils.stdoutString
import com.intellij.python.community.execService.Args
import com.intellij.python.community.execService.ExecOptions
import com.intellij.python.community.execService.ExecService
import com.intellij.python.community.execService.ZeroCodeStdoutTransformer
@@ -28,12 +29,12 @@ import kotlin.time.Duration.Companion.minutes
internal suspend fun ExecService.validatePythonAndGetVersionImpl(python: ExecutablePython): PyResult<LanguageLevel> = withContext(Dispatchers.IO) {
val options = ExecOptions(timeout = 1.minutes)
val smokeTestOutput = executePythonAdvanced(python, { addArgs("-c", "print(1)") }, processInteractiveHandler = transformerToHandler(null, ZeroCodeStdoutTransformer), options = options).getOr(message("python.cannot.exec", python.userReadableName)) { return@withContext it }.trim()
val smokeTestOutput = executePythonAdvanced(python, Args("-c", "print(1)"), processInteractiveHandler = transformerToHandler(null, ZeroCodeStdoutTransformer), options = options).getOr(message("python.cannot.exec", python.userReadableName)) { return@withContext it }.trim()
if (smokeTestOutput != "1") {
return@withContext PyResult.localizedError(message("python.get.version.error", python.userReadableName, smokeTestOutput))
}
val versionOutput: EelProcessExecutionResult = executePythonAdvanced(python, options = options, argsBuilder = { addArgs(PYTHON_VERSION_ARG) }, processInteractiveHandler = transformerToHandler<EelProcessExecutionResult>(null, { r ->
val versionOutput: EelProcessExecutionResult = executePythonAdvanced(python, options = options, args = Args(PYTHON_VERSION_ARG), processInteractiveHandler = transformerToHandler<EelProcessExecutionResult>(null, { r ->
if (r.exitCode == 0) Result.success(r) else Result.failure(message("python.get.version.error", python.userReadableName, r.exitCode))
})).getOr { return@withContext it }
// Python 2 might return version as stderr, see https://bugs.python.org/issue18338
@@ -26,5 +26,7 @@
<orderEntry type="library" scope="TEST" name="JUnit5Pioneer" level="project" />
<orderEntry type="module" module-name="intellij.platform.testFramework.common" scope="TEST" />
<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" />
</component>
</module>
@@ -3,7 +3,6 @@ package com.intellij.python.community.execService
import kotlinx.coroutines.flow.FlowCollector
import org.jetbrains.annotations.ApiStatus
import java.nio.file.Path
/**
* Listens for start/stop/std{out,err} events
@@ -11,7 +10,7 @@ import java.nio.file.Path
typealias PyProcessListener = FlowCollector<ProcessEvent>
sealed interface ProcessEvent {
data class ProcessStarted @ApiStatus.Internal constructor(val binary: Path, val args: List<String>) : ProcessEvent
data class ProcessStarted @ApiStatus.Internal constructor(val binary: BinaryToExec, val args: List<String>) : ProcessEvent
data class ProcessOutput @ApiStatus.Internal constructor(val stream: OutputType, val line: String) : ProcessEvent
data class ProcessEnded @ApiStatus.Internal constructor(val exitCode: Int) : ProcessEvent
@@ -1,22 +1,20 @@
// 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.platform.eel.EelProcess
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.PyExecResult
import com.jetbrains.python.errorProcessing.PyResult
import kotlinx.coroutines.Deferred
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.CheckReturnValue
import org.jetbrains.annotations.Nls
import java.nio.file.Path
// This is an advanced API, consider using basic api.kt
/**
* Service is a thin wrapper over [com.intellij.platform.eel.EelApi] to execute python tools on local or remote Eel.
* to obtain service, use function with same name.
@@ -34,18 +32,16 @@ interface ExecService {
* You must listen for process stdout/stderr e.t.c.
* Use it if you need to get some info from a process before it ends or to interact (i.e. write into stdin).
* See [ProcessInteractiveHandler] and [processSemiInteractiveHandler].
* [argsBuilder] is a lambda to build args, see [ArgsBuilder]
*/
@CheckReturnValue
suspend fun <T> executeAdvanced(
binary: Path,
argsBuilder: suspend ArgsBuilder.() -> Unit = {},
binary: BinaryToExec,
args: Args,
options: ExecOptions = ExecOptions(),
processInteractiveHandler: ProcessInteractiveHandler<T>,
): PyExecResult<T>
): PyResult<T>
}
/**
* Message to be displayed to a user in case of process failure.
*/
@@ -61,7 +57,7 @@ fun interface ProcessInteractiveHandler<T> {
* In latter case returns [EelProcessExecutionResult] (created out of collected output) and optional error message.
* If no message returned -- the default one is used.
*/
suspend fun getResultFromProcess(binary: Path, args: List<String>, process: EelProcess): Result<T, Pair<EelProcessExecutionResult, CustomErrorMessage?>>
suspend fun getResultFromProcess(binary: BinaryToExec, args: List<String>, process: Process): Result<T, Pair<EelProcessExecutionResult, CustomErrorMessage?>>
}
@@ -76,18 +72,3 @@ 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)
/**
* ```kotlin
* addLocalFile(helper)
* addTextArgs("-v")
* ```
*/
interface ArgsBuilder {
fun addArgs(vararg args: String)
/**
* This file will be copied to eel and its remote name will be added to the list of arguments
*/
suspend fun addLocalFile(localFile: Path)
}
@@ -3,6 +3,8 @@ package com.intellij.python.community.execService
import com.intellij.execution.process.AnsiEscapeDecoder
import com.intellij.execution.process.ProcessOutputTypes
import com.intellij.execution.target.FullPathOnTarget
import com.intellij.execution.target.TargetEnvironmentConfiguration
import com.intellij.openapi.util.NlsSafe
import com.intellij.platform.eel.EelApi
import com.intellij.platform.eel.getShell
@@ -10,6 +12,7 @@ import com.intellij.platform.eel.provider.asNioPath
import com.intellij.platform.eel.provider.utils.EelProcessExecutionResult
import com.intellij.platform.eel.provider.utils.stdoutString
import com.intellij.platform.util.progress.reportRawProgress
import com.intellij.python.community.execService.impl.Arg
import com.intellij.python.community.execService.impl.ExecServiceImpl
import com.intellij.python.community.execService.impl.PyExecBundle
import com.intellij.python.community.execService.impl.transformerToHandler
@@ -19,6 +22,7 @@ import com.jetbrains.python.errorProcessing.PyResult
import org.jetbrains.annotations.CheckReturnValue
import org.jetbrains.annotations.Nls
import java.nio.file.Path
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
@@ -29,6 +33,24 @@ import kotlin.time.Duration.Companion.minutes
fun ExecService(): ExecService = ExecServiceImpl
/**
* There are two ways to execute binary:
*/
sealed interface BinaryToExec
/**
* [path] on eel (Use it for anything but SSH).
* [workDir] is pwd. As it should be on the same eel as [path] for most cases (except WSL), it is better not to set it at all.
* Prefer full [path] over relative.
*/
data class BinOnEel(val path: Path, val workDir: Path? = null) : BinaryToExec
/**
* Legacy Targets-based approach. Do not use it, unless you know what you are doing
*/
data class BinOnTarget(val path: FullPathOnTarget, val target: TargetEnvironmentConfiguration) : BinaryToExec
/**
* Execute [binary] right directly on the eel it resides on.
*/
@@ -37,6 +59,17 @@ suspend fun ExecService.execGetStdout(
args: List<String> = emptyList(),
options: ExecOptions = ExecOptions(),
procListener: PyProcessListener? = null,
): PyResult<String> = execGetStdout(BinOnEel(binary), args, options, procListener)
/**
* Execute [binary] right directly where it sits
*/
suspend fun ExecService.execGetStdout(
binary: BinaryToExec,
args: List<String> = emptyList(),
options: ExecOptions = ExecOptions(),
procListener: PyProcessListener? = null,
): PyResult<String> = execute(
binary = binary,
args = args,
@@ -59,7 +92,7 @@ suspend fun ExecService.execGetStdout(
): PyResult<String> {
val binary = eelApi.exec.findExeFilesInPath(binaryName).firstOrNull()?.asNioPath()
?: return PyResult.localizedError(PyExecBundle.message("py.exec.fileNotFound", binaryName, eelApi.descriptor.machine.name))
return execGetStdout(binary, args, options, procListener)
return execGetStdout(BinOnEel(binary), args, options, procListener)
}
@@ -75,7 +108,7 @@ suspend fun ExecService.execGetStdoutInShell(
procListener: PyProcessListener? = null,
): PyResult<String> {
val (shell, arg) = eelApi.exec.getShell()
return execGetStdout(shell.asNioPath(), listOf(arg, commandForShell) + args, options, procListener)
return execGetStdout(BinOnEel(shell.asNioPath()), listOf(arg, commandForShell) + args, options, procListener)
}
/**
@@ -88,7 +121,7 @@ suspend fun ExecService.execGetStdoutInShell(
*/
@CheckReturnValue
suspend fun <T> ExecService.execute(
binary: Path,
binary: BinaryToExec,
args: List<String> = emptyList(),
options: ExecOptions = ExecOptions(),
procListener: PyProcessListener? = null,
@@ -111,8 +144,8 @@ suspend fun <T> ExecService.execute(
}
}
}
executeAdvanced(binary, { addArgs(*args.toTypedArray()) }, options, transformerToHandler(procListener
?: listener, processOutputTransformer))
executeAdvanced(binary, Args(*args.toTypedArray()), options, transformerToHandler(procListener
?: listener, processOutputTransformer))
}
}
@@ -130,14 +163,65 @@ object ZeroCodeStdoutTransformer : ProcessOutputTransformer<String> {
/**
* @property[workingDirectory] Directory where to run the process (PWD)
* @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
*/
data class ExecOptions(
val env: Map<String, String> = emptyMap(),
val workingDirectory: Path? = null,
val processDescription: @Nls String? = null,
val timeout: Duration = 5.minutes,
)
/**
* See [Args.addLocalFile]
*/
fun interface FileArgGenerator {
fun generateArg(remoteFile: String): String
}
/**
* ```kotlin
* val args = Args()
* args.addLocalFile(helper)
* args.addTextArgs("-v")
* ```
*/
class Args(vararg initialArgs: String) {
private val _args = CopyOnWriteArrayList<Arg>(initialArgs.map { Arg.StringArg(it) })
fun addArgs(vararg args: String) {
_args.addAll(args.map { Arg.StringArg(it) })
}
/**
* This file will be copied to remote machine and its remote name will be added to the list of arguments.
* Use [argGenerator] to modify name
*/
fun addLocalFile(localFile: Path, argGenerator: FileArgGenerator = FileArgGenerator { it }) {
_args.add(Arg.FileArg(localFile, argGenerator))
}
operator fun plus(second: Args): Args {
val new = Args()
new._args.addAll(_args)
new._args.addAll(second._args)
return new
}
internal val localFiles: List<Path>
get() = _args.mapNotNull {
when (it) {
is Arg.FileArg -> it.file
is Arg.StringArg -> null
}
}
internal suspend fun getArgs(mapFileToRemote: suspend (local: Path) -> String): List<String> =
_args.map {
when (it) {
is Arg.StringArg -> it.arg
is Arg.FileArg -> it.generator.generateArg(mapFileToRemote(it.file))
}
}
}
@@ -2,142 +2,80 @@
package com.intellij.python.community.execService.impl
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.platform.eel.EelApi
import com.intellij.platform.eel.EelProcess
import com.intellij.platform.eel.ExecuteProcessException
import com.intellij.platform.eel.path.EelPath
import com.intellij.platform.eel.provider.asEelPath
import com.intellij.platform.eel.provider.getEelDescriptor
import com.intellij.platform.eel.provider.utils.EelPathUtils
import com.intellij.platform.eel.spawnProcess
import com.intellij.python.community.execService.ArgsBuilder
import com.intellij.python.community.execService.ExecOptions
import com.intellij.python.community.execService.ExecService
import com.intellij.python.community.execService.ProcessInteractiveHandler
import com.intellij.python.community.execService.*
import com.intellij.python.community.execService.impl.processLaunchers.*
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.*
import kotlinx.coroutines.*
import org.jetbrains.annotations.CheckReturnValue
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 kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withTimeout
import org.jetbrains.annotations.Nls
import java.nio.file.Path
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.io.path.exists
import kotlin.io.path.pathString
import kotlin.time.Duration
internal object ExecServiceImpl : ExecService {
override suspend fun <T> executeAdvanced(binary: Path, argsBuilder: suspend ArgsBuilder.() -> Unit, options: ExecOptions, processInteractiveHandler: ProcessInteractiveHandler<T>): PyExecResult<T> {
val args = ArgsBuilderImpl(binary.getEelDescriptor().toEelApi()).apply { argsBuilder() }.args
val description = options.processDescription
?: PyExecBundle.message("py.exec.defaultName.process", (listOf(binary.pathString) + args).joinToString(" "))
override suspend fun <T> executeAdvanced(binary: BinaryToExec, args: Args, options: ExecOptions, processInteractiveHandler: ProcessInteractiveHandler<T>): PyResult<T> {
return coroutineScope {
val request = LaunchRequest(this, args, options.env)
val processLauncher: ProcessLauncher = when (binary) {
is BinOnEel -> createProcessLauncherOnEel(binary, request)
is BinOnTarget -> createProcessLauncherOnTarget(binary, request).getOr { return@coroutineScope it }
}
val description = options.processDescription
?: PyExecBundle.message("py.exec.defaultName.process", (listOf(processLauncher.exeForError.toString()) + processLauncher.args).joinToString(" "))
val process = processLauncher.start().getOr {
val message = PyExecBundle.message("py.exec.start.error", description, it.error.cantExecProcessError, it.error.errNo
?: "unknown")
return@coroutineScope processLauncher.createExecError(
messageToUser = message,
errorReason = it.error
)
}
val binary = if (binary.isAbsolute) binary else options.workingDirectory?.resolve(binary) ?: binary.toAbsolutePath()
val eelPath = binary.asEelPath()
val executableProcess = EelExecutableProcess(eelPath, args, options.env, options.workingDirectory, description)
val eelProcess = executableProcess.run(this).getOr { return@coroutineScope it }
val result = try {
withTimeout(options.timeout) {
val interactiveResult = processInteractiveHandler.getResultFromProcess(binary, args, eelProcess)
val interactiveResult = processInteractiveHandler.getResultFromProcess(binary, processLauncher.args, process)
val successResult = interactiveResult.getOr { failure ->
val (output, customErrorMessage) = failure.error
return@withTimeout executableProcess.failAsExecutionFailed(ExecErrorReason.UnexpectedProcessTermination(output), customErrorMessage)
val additionalMessage = customErrorMessage ?: run {
PyExecBundle.message("py.exec.exitCode.error", description, output.exitCode)
}
return@withTimeout processLauncher.createExecError(
messageToUser = additionalMessage,
errorReason = ExecErrorReason.UnexpectedProcessTermination(output)
)
}
Result.success(successResult)
}
}
catch (_: TimeoutCancellationException) {
executableProcess.killProcessAndFailAsTimeout(eelProcess, options.timeout)
processLauncher.killAndJoin()
processLauncher.createExecError(
messageToUser = PyExecBundle.message("py.exec.timeout.error", description, options.timeout),
errorReason = ExecErrorReason.Timeout
)
}
return@coroutineScope result
}
}
}
private data class EelExecutableProcess(
val exe: EelPath,
val args: List<String>,
val env: Map<String, String>,
val workingDirectory: Path?,
val description: @Nls String,
)
@CheckReturnValue
private suspend fun EelExecutableProcess.run(scopeToBound: CoroutineScope): PyExecResult<EelProcess> {
val workingDirectory = if (workingDirectory != null && !workingDirectory.isAbsolute) workingDirectory.toRealPath() else workingDirectory
try {
val executionResult = exe.descriptor.toEelApi().exec.spawnProcess(exe.toString())
.scope(scopeToBound)
.args(args)
.env(env)
.workingDirectory(workingDirectory?.asEelPath())
.eelIt()
return Result.success(executionResult)
}
catch (e: ExecuteProcessException) {
return failAsCantStart(e)
}
}
private fun EelExecutableProcess.failAsCantStart(executeProcessError: ExecuteProcessException): Result.Failure<ExecError> {
return ExecError(
exe = Exe.OnEel(exe),
private fun ProcessLauncher.createExecError(messageToUser: @Nls String, errorReason: ExecErrorReason): Result.Failure<ExecError> =
ExecError(
exe = exeForError,
args = args.toTypedArray(),
additionalMessageToUser = PyExecBundle.message("py.exec.start.error", description, executeProcessError.message, executeProcessError.errno),
errorReason = ExecErrorReason.CantStart(executeProcessError.errno, executeProcessError.message)
additionalMessageToUser = messageToUser,
errorReason = errorReason
).logAndFail()
}
private suspend fun EelExecutableProcess.killProcessAndFailAsTimeout(eelProcess: EelProcess, timeout: Duration): Result.Failure<ExecError> {
eelProcess.interrupt()
eelProcess.kill()
eelProcess.exitCode.await()
return ExecError(
exe = Exe.OnEel(exe),
args = args.toTypedArray(),
additionalMessageToUser = PyExecBundle.message("py.exec.timeout.error", description, timeout),
errorReason = ExecErrorReason.Timeout
).logAndFail()
}
private fun EelExecutableProcess.failAsExecutionFailed(processOutput: ExecErrorReason.UnexpectedProcessTermination, customMessage: @Nls String?): Result.Failure<ExecError> {
val additionalMessage = customMessage ?: run {
PyExecBundle.message("py.exec.exitCode.error", description, processOutput.exitCode)
}
return ExecError(
exe = Exe.OnEel(exe),
args = args.toTypedArray(),
additionalMessageToUser = additionalMessage,
errorReason = processOutput
).logAndFail()
}
private fun ExecError.logAndFail(): Result.Failure<ExecError> {
fileLogger().warn(message)
return failure(this)
}
private class ArgsBuilderImpl(private val eel: EelApi) : ArgsBuilder {
private val _args = CopyOnWriteArrayList<String>()
val args: List<String> = _args
override fun addArgs(vararg args: String) {
_args.addAll(args)
}
override suspend fun addLocalFile(localFile: Path): Unit = withContext(Dispatchers.IO) {
assert(localFile.exists()) { "No file $localFile, be sure to check it before calling" }
val remoteFile = EelPathUtils.transferLocalContentToRemote(
source = localFile,
target = EelPathUtils.TransferTarget.Temporary(eel.descriptor)
).asEelPath().toString()
_args.add(remoteFile)
}
}
@@ -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.impl
import java.nio.file.Path
internal sealed interface Arg {
data class StringArg(val arg: String) : Arg
data class FileArg(val file: Path, val generator: com.intellij.python.community.execService.FileArgGenerator) : Arg
}
@@ -1,25 +1,25 @@
// 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.platform.eel.EelProcess
import com.intellij.platform.eel.provider.utils.EelProcessExecutionResult
import com.intellij.platform.eel.provider.utils.asEelChannel
import com.intellij.python.community.execService.*
import com.intellij.util.io.awaitExit
import com.jetbrains.python.Result
import com.jetbrains.python.mapError
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import java.nio.file.Path
internal class ProcessSemiInteractiveHandlerImpl<T>(
private val pyProcessListener: PyProcessListener?,
private val code: ProcessSemiInteractiveFun<T>,
) : ProcessInteractiveHandler<T> {
override suspend fun getResultFromProcess(binary: Path, args: List<String>, process: EelProcess): Result<T, Pair<EelProcessExecutionResult, CustomErrorMessage?>> =
override suspend fun getResultFromProcess(binary: BinaryToExec, args: List<String>, process: Process): Result<T, Pair<EelProcessExecutionResult, CustomErrorMessage?>> =
coroutineScope {
pyProcessListener?.emit(ProcessEvent.ProcessStarted(binary, args))
val processOutput = async { process.awaitWithReporting(pyProcessListener) }
val result = code(process.stdin, processOutput)
pyProcessListener?.emit(ProcessEvent.ProcessEnded(process.exitCode.await()))
val result = code(process.outputStream.asEelChannel(), processOutput)
pyProcessListener?.emit(ProcessEvent.ProcessEnded(process.awaitExit()))
return@coroutineScope result.mapError { customErrorMessage ->
Pair(processOutput.await(), customErrorMessage)
}
@@ -1,36 +1,37 @@
// 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.platform.eel.EelProcess
import com.intellij.platform.eel.provider.utils.EelProcessExecutionResult
import com.intellij.platform.eel.provider.utils.consumeAsInputStream
import com.intellij.python.community.execService.ProcessEvent
import com.intellij.python.community.execService.ProcessEvent.OutputType
import com.intellij.python.community.execService.ProcessEvent.OutputType.STDERR
import com.intellij.python.community.execService.ProcessEvent.OutputType.STDOUT
import kotlinx.coroutines.*
import com.intellij.util.io.awaitExit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.FlowCollector
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.withContext
/**
* Awaits of process result and reports its stdout/stderr as a progress.
*/
internal suspend fun EelProcess.awaitWithReporting(progressListener: FlowCollector<ProcessEvent.ProcessOutput>?): EelProcessExecutionResult =
internal suspend fun Process.awaitWithReporting(progressListener: FlowCollector<ProcessEvent.ProcessOutput>?): EelProcessExecutionResult =
coroutineScope {
val stdout = async { report(STDOUT, progressListener) }
val stderr = async { report(STDERR, progressListener) }
EelProcessExecutionResult(exitCode.await(), stdout = stdout.await(), stderr = stderr.await())
EelProcessExecutionResult(awaitExit(), stdout = stdout.await(), stderr = stderr.await())
}
private suspend fun EelProcess.report(outputType: OutputType, to: FlowCollector<ProcessEvent.ProcessOutput>?): ByteArray = withContext(Dispatchers.IO) {
private suspend fun Process.report(outputType: OutputType, to: FlowCollector<ProcessEvent.ProcessOutput>?): ByteArray = withContext(Dispatchers.IO) {
val from = when (outputType) {
STDOUT -> stdout
STDERR -> stderr
STDOUT -> inputStream
STDERR -> errorStream
}
val result = StringBuilder()
from.consumeAsInputStream().use { inputStream ->
from.use { inputStream ->
val reader = inputStream.reader()
val currentLine = StringBuilder()
@@ -0,0 +1,34 @@
// 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.openapi.diagnostic.fileLogger
import com.intellij.platform.eel.provider.utils.ProcessFunctions
import com.intellij.python.community.execService.Args
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.Exe
import com.jetbrains.python.errorProcessing.ExecErrorReason
import kotlinx.coroutines.CoroutineScope
private val logger = fileLogger()
internal class ProcessLauncher(
val exeForError: Exe,
val args: List<String>,
private val processCommands: ProcessCommands,
) {
suspend fun start(): Result<Process, ExecErrorReason.CantStart> = processCommands.start()
suspend fun killAndJoin() {
processCommands.processFunctions.killAndJoin(logger, exeForError.toString())
}
}
internal interface ProcessCommands {
suspend fun start(): Result<Process, ExecErrorReason.CantStart>
val processFunctions: ProcessFunctions
}
internal data class LaunchRequest(
val scopeToBind: CoroutineScope,
val args: Args,
val env: Map<String, String>,
)
@@ -0,0 +1,67 @@
// 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.EelProcess
import com.intellij.platform.eel.ExecuteProcessException
import com.intellij.platform.eel.path.EelPath
import com.intellij.platform.eel.provider.asEelPath
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.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.Exe
import com.jetbrains.python.errorProcessing.ExecErrorReason
import kotlinx.coroutines.CoroutineScope
internal suspend fun createProcessLauncherOnEel(binOnEel: BinOnEel, launchRequest: LaunchRequest): ProcessLauncher {
val exePath: EelPath = with(binOnEel) {
(if (path.isAbsolute) path else workDir?.resolve(binOnEel.path) ?: path.toAbsolutePath()).asEelPath()
}
val eel = exePath.descriptor.toEelApi()
val args = launchRequest.args.getArgs { file ->
EelPathUtils.transferLocalContentToRemote(
source = file,
target = EelPathUtils.TransferTarget.Temporary(eel.descriptor)
).asEelPath().toString()
}
return ProcessLauncher(
exeForError = Exe.OnEel(exePath),
args = args,
processCommands = EelProcessCommands(launchRequest.scopeToBind, binOnEel, exePath, args, launchRequest.env)
)
}
private class EelProcessCommands(
private val scopeToBind: CoroutineScope,
private val binOnEel: BinOnEel,
private val path: EelPath,
private val args: List<String>,
private val env: Map<String, String>,
) : ProcessCommands {
private var eelProcess: EelProcess? = null
override val processFunctions: ProcessFunctions = ProcessFunctions(
waitForExit = { eelProcess?.exitCode?.await() },
killProcess = { eelProcess?.kill() }
)
override suspend fun start(): Result<Process, ExecErrorReason.CantStart> {
var workDir = binOnEel.workDir
workDir = if (workDir != null && !workDir.isAbsolute) workDir.toRealPath() else workDir
try {
val eelProcess = path.descriptor.toEelApi().exec.spawnProcess(path)
.scope(scopeToBind)
.args(args)
.env(env)
.workingDirectory(workDir?.asEelPath())
.eelIt()
this.eelProcess = eelProcess
return Result.success(eelProcess.convertToJavaProcess())
}
catch (e: ExecuteProcessException) {
return Result.failure(ExecErrorReason.CantStart(e.errno, e.message))
}
}
}
@@ -0,0 +1,8 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
/**
* There are two launchers: Eel (modern) and targets (legacy)
*/
@ApiStatus.Internal
package com.intellij.python.community.execService.impl.processLaunchers;
import org.jetbrains.annotations.ApiStatus;
@@ -0,0 +1,102 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
@file:Suppress("UsePlatformProcessAwaitExit")
package com.intellij.python.community.execService.impl.processLaunchers
import com.intellij.execution.ExecutionException
import com.intellij.execution.target.*
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.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
import kotlinx.coroutines.withContext
import kotlin.io.path.pathString
import kotlin.time.Duration.Companion.milliseconds
private val logger = fileLogger()
internal suspend fun createProcessLauncherOnTarget(binOnTarget: BinOnTarget, launchRequest: LaunchRequest): PyResult<ProcessLauncher> = withContext(Dispatchers.IO) {
val target = binOnTarget.target
val projectMan = ProjectManager.getInstance()
// Broken Targets API doesn't work without project
val request = target.createEnvironmentRequest(projectMan.openProjects.firstOrNull() ?: projectMan.defaultProject)
// Broken Targets API can only upload the whole directory
val dirsToMap = launchRequest.args.localFiles.map { it.parent }.toSet()
for (localDir in dirsToMap) {
request.uploadVolumes.add(TargetEnvironment.UploadRoot(localDir, TargetEnvironment.TargetPath.Temporary(), removeAtShutdown = true))
}
val targetEnv = try {
request.prepareEnvironment(TargetProgressIndicator.EMPTY)
}
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}"))
}
val args = launchRequest.args.getArgs { localFile ->
targetEnv.getTargetPaths(localFile.pathString).first()
}
return@withContext Result.success(
ProcessLauncher(
exeForError = Exe.OnTarget(binOnTarget.path),
args = args,
processCommands = TargetProcessCommands(launchRequest.scopeToBind, binOnTarget.path, request, targetEnv, args, launchRequest.env)
)
)
}
private class TargetProcessCommands(
private val scopeToBind: CoroutineScope,
private val exePath: FullPathOnTarget,
private val request: TargetEnvironmentRequest,
private val targetEnv: TargetEnvironment,
private val args: List<String>,
private val env: Map<String, String>,
) : ProcessCommands {
private var process: Process? = null
override val processFunctions: ProcessFunctions = ProcessFunctions(
waitForExit = {
// `waitForExit` seems to be broken in Targets API, hence polling
while (process?.isAlive == true) {
delay(100.milliseconds)
}
},
killProcess = { process?.destroyForcibly() }
)
override suspend fun start(): Result<Process, ExecErrorReason.CantStart> {
val cmdLine = TargetedCommandLineBuilder(request).also {
it.setExePath(exePath)
it.addParameters(args)
for ((k, v) in env) {
it.addEnvironmentVariable(k, v)
}
}.build()
try {
val process = targetEnv.createProcess(cmdLine)
this.process = process
scopeToBind.bindProcessToScopeImpl(
logger = logger,
processNameForDebug = exePath,
processFunctions = processFunctions
)
return Result.success(process)
}
catch (e: ExecutionException) {
return e.asCantStart()
}
}
}
private fun ExecutionException.asCantStart(): Result.Failure<ExecErrorReason.CantStart> = Result.failure(ExecErrorReason.CantStart(null, localizedMessage))
@@ -4,9 +4,7 @@ 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.readWholeText
import com.intellij.platform.eel.provider.utils.sendWholeText
import com.intellij.platform.eel.provider.utils.stdoutString
import com.intellij.platform.eel.provider.utils.*
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
@@ -15,6 +13,7 @@ import com.intellij.testFramework.common.timeoutRunBlocking
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.Exe
import com.jetbrains.python.errorProcessing.ExecError
import com.jetbrains.python.errorProcessing.MessageError
import com.jetbrains.python.getOrThrow
import kotlinx.coroutines.*
import org.hamcrest.CoreMatchers
@@ -109,7 +108,7 @@ class ExecServiceShowCaseTest {
val (shell, execArg) = eel.exec.getShell()
val args = listOf(execArg, "echo Alice,25 && echo Bob,48")
val records = ExecService().execute((shell.asNioPath()), args) { output ->
val records = ExecService().execute((BinOnEel(shell.asNioPath())), args) { output ->
val stdout = output.stdoutString.trim()
when {
output.exitCode == 123 -> {
@@ -159,17 +158,36 @@ class ExecServiceShowCaseTest {
assertThat("Command doesn't have expected output", output, CoreMatchers.containsString(expectedPhrase))
}
@ParameterizedTest
@EelSource
fun testInteractive(eelHolder: EelHolder): Unit = timeoutRunBlocking {
@CartesianTest
fun testInteractive(
@EelSource eelHolder: EelHolder,
@CartesianTest.Values(booleans = [true, false]) useEelChannels: Boolean,
): Unit = timeoutRunBlocking(13.minutes, context = Dispatchers.IO) {
val string = "abc123"
val shell = eelHolder.eel.exec.getShell().first.asNioPath()
val output = ExecService().executeAdvanced(shell, {}, processInteractiveHandler = ProcessInteractiveHandler<String> { _, _, process ->
val stdout = async {
process.stdout.readWholeText()
val output = ExecService().executeAdvanced(BinOnEel(shell), Args(), processInteractiveHandler = ProcessInteractiveHandler<String> { _, _, process ->
val stdout = this@timeoutRunBlocking.async {
if (useEelChannels) {
process.inputStream.consumeAsEelChannel().readWholeText()
}
else {
process.inputStream.bufferedReader().readText()
}
}
val commands = arrayOf("echo $string\n", "exit\n")
if (useEelChannels) {
val eelChannel = process.outputStream.asEelChannel()
for (cmd in commands) {
eelChannel.sendWholeText(cmd)
}
}
else {
val writer = process.outputStream.bufferedWriter()
for (cmd in commands) {
writer.write(cmd)
writer.flush()
}
}
process.stdin.sendWholeText("echo $string\n")
process.stdin.sendWholeText("exit\n")
Result.success(stdout.await())
}).orThrow()
assertThat("No expected output", output, CoreMatchers.containsString(string))
@@ -182,7 +200,7 @@ class ExecServiceShowCaseTest {
): Unit = timeoutRunBlocking {
val messageToUser = "abc123"
val shell = eelHolder.eel.exec.getShell().first.asNioPath()
val result = ExecService().executeAdvanced(shell, {}, processInteractiveHandler = processSemiInteractiveHandler<Unit> { channel, exitCode ->
val result = ExecService().executeAdvanced(BinOnEel(shell), Args(), processInteractiveHandler = processSemiInteractiveHandler<Unit> { channel, exitCode ->
channel.sendWholeText("exit\n")
assertEquals(0, exitCode.await().exitCode, "Wrong exit code")
if (sunny) {
@@ -194,9 +212,17 @@ class ExecServiceShowCaseTest {
})
when (result) {
is Result.Failure -> {
assertFalse(sunny, "Unexpected failure ${result.error}")
assertThat("Wrong message to user", result.error.message, CoreMatchers.containsString(messageToUser))
assertEquals(shell, (result.error.exe as Exe.OnEel).eelPath.asNioPath(), "Wrong exe")
when (val err = result.error) {
is ExecError -> {
assertFalse(sunny, "Unexpected failure ${result.error}")
assertThat("Wrong message to user",
result.error.message, CoreMatchers.containsString(messageToUser))
assertEquals(shell, (err.exe as Exe.OnEel).eelPath.asNioPath(), "Wrong exe")
}
is MessageError -> {
fail("Unexpected error $err")
}
}
}
is Result.Success -> {
assertTrue(sunny, "Unexpected success")
@@ -236,7 +262,7 @@ class ExecServiceShowCaseTest {
val progressCapturer = PyProcessListener { event ->
when (event) {
is ProcessEvent.ProcessStarted -> {
assertEquals(shell, event.binary, "Wrong args for start event")
assertEquals(shell, (event.binary as BinOnEel).path, "Wrong args for start event")
processStartEvent = true
}
is ProcessEvent.ProcessOutput -> {
@@ -248,7 +274,7 @@ class ExecServiceShowCaseTest {
}
}
ExecService().executeAdvanced(shell, argsBuilder = {}, processInteractiveHandler = processSemiInteractiveHandler<Unit>(progressCapturer) { stdin, _ ->
ExecService().executeAdvanced(BinOnEel(shell), args = Args(), processInteractiveHandler = processSemiInteractiveHandler<Unit>(progressCapturer) { stdin, _ ->
for (string in text) {
stdin.sendWholeText("echo $string\n")
delay(500)
@@ -16,7 +16,7 @@ import kotlin.io.path.isExecutable
import kotlin.time.Duration.Companion.minutes
class HatchRuntime(
val hatchBinary: Path,
val hatchBinary: BinOnEel,
val execOptions: ExecOptions,
private val execService: ExecService = ExecService(),
) {
@@ -35,8 +35,8 @@ class HatchRuntime(
}
val runtime = HatchRuntime(
hatchBinary = this.hatchBinary,
execOptions = this.execOptions.copy(workingDirectory = workDirectoryPath)
hatchBinary = this.hatchBinary.copy(workDir = workDirectoryPath),
execOptions = this.execOptions
)
return Result.success(runtime)
}
@@ -59,7 +59,7 @@ class HatchRuntime(
}
internal suspend fun <T> executeInteractive(vararg arguments: String, processSemiInteractiveFun: ProcessSemiInteractiveFun<T>): PyResult<T> {
return execService.executeAdvanced(hatchBinary, { addArgs(*arguments) }, execOptions, processSemiInteractiveHandler(code = processSemiInteractiveFun))
return execService.executeAdvanced(hatchBinary, Args(*arguments), execOptions, processSemiInteractiveHandler(code = processSemiInteractiveFun))
}
internal suspend fun resolvePythonVirtualEnvironment(pythonHomePath: PythonHomePath): PyResult<PythonVirtualEnvironment> {
@@ -101,10 +101,9 @@ suspend fun createHatchRuntime(
val actualEnvVars = defaultVariables + envVars
val runtime = HatchRuntime(
hatchBinary = actualHatchExecutable,
hatchBinary = BinOnEel(actualHatchExecutable, workingDirectoryPath),
execOptions = ExecOptions(
env = actualEnvVars,
workingDirectory = workingDirectoryPath
)
)
return Result.success(runtime)
@@ -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.jetbrains.python.sdk
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
@@ -28,6 +29,6 @@ suspend fun runExecutableWithProgress(
env: Map<String, String> = emptyMap(),
vararg args: String,
): PyResult<String> {
val execOptions = ExecOptions(workingDirectory = workDir, timeout = timeout, env = env)
return ExecService().execGetStdout(executable, args.toList(), execOptions)
val execOptions = ExecOptions(timeout = timeout, env = env)
return ExecService().execGetStdout(BinOnEel(executable, workDir), args.toList(), execOptions)
}
@@ -9,7 +9,7 @@ import com.intellij.openapi.ui.ValidationInfo
import com.intellij.openapi.util.SystemInfo
import com.intellij.openapi.util.io.FileUtil
import com.intellij.openapi.util.registry.Registry
import com.intellij.python.community.execService.ExecOptions
import com.intellij.python.community.execService.BinOnEel
import com.intellij.python.community.execService.ExecService
import com.intellij.python.community.execService.execGetStdout
import com.intellij.python.community.impl.poetry.poetryPath
@@ -119,7 +119,7 @@ suspend fun setupPoetry(projectPath: Path, python: String?, installPackages: Boo
.getOr { return it }
if (python != null) { // Replace a python version in toml
ExecService().execGetStdout(Path.of(python), listOf("-c", REPLACE_PYTHON_VERSION), ExecOptions(workingDirectory = projectPath))
ExecService().execGetStdout(BinOnEel(Path.of(python), workDir = projectPath), listOf("-c", REPLACE_PYTHON_VERSION))
.getOr { return it }
}
}