From 8f83b52355be2340b147ea5ca4648971aaab8ed6 Mon Sep 17 00:00:00 2001 From: Ilya Kazakevich Date: Wed, 6 Aug 2025 16:10:47 +0000 Subject: [PATCH] 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 GitOrigin-RevId: 4c4ac7023e6605caaacb1880d60faf145b4160bf --- .../platform/eel/provider/utils/impl.kt | 24 +-- .../src/advancedApi/advancedApi.kt | 5 +- python/interpreters/src/api.kt | 7 +- .../python/errorProcessing/ExecError.kt | 2 +- python/python-exec-service/BUILD.bazel | 4 + .../src/advancedApi/advancedApi.kt | 25 +-- .../execService.python/src/impl/impl.kt | 5 +- .../intellij.python.community.execService.iml | 2 + .../community/execService/ProgressListener.kt | 3 +- .../community/execService/advancedApi.kt | 29 +--- .../python/community/execService/api.kt | 98 +++++++++++- .../execService/impl/ExecServiceImpl.kt | 150 +++++------------- .../community/execService/impl/argImpl.kt | 10 ++ .../impl/interactiveHandlersImpl.kt | 10 +- .../execService/impl/processAwaiter.kt | 21 +-- .../impl/processLaunchers/ProcessLauncher.kt | 34 ++++ .../execService/impl/processLaunchers/eel.kt | 67 ++++++++ .../impl/processLaunchers/package-info.java | 8 + .../impl/processLaunchers/targets.kt | 102 ++++++++++++ .../unit/alsoWin/ExecServiceShowCaseTest.kt | 62 +++++--- .../python/hatch/runtime/HatchRuntime.kt | 11 +- .../python/sdk/PySdkCommandRunner.kt | 5 +- .../sdk/poetry/PoetryCommandExecutor.kt | 4 +- 23 files changed, 473 insertions(+), 215 deletions(-) create mode 100644 python/python-exec-service/src/com/intellij/python/community/execService/impl/argImpl.kt create mode 100644 python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/ProcessLauncher.kt create mode 100644 python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/eel.kt create mode 100644 python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/package-info.java create mode 100644 python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/targets.kt diff --git a/platform/eel-provider/src/com/intellij/platform/eel/provider/utils/impl.kt b/platform/eel-provider/src/com/intellij/platform/eel/provider/utils/impl.kt index 7ce775aaa459..29a19757788a 100644 --- a/platform/eel-provider/src/com/intellij/platform/eel/provider/utils/impl.kt +++ b/platform/eel-provider/src/com/intellij/platform/eel/provider/utils/impl.kt @@ -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) { diff --git a/python/interpreters/src/advancedApi/advancedApi.kt b/python/interpreters/src/advancedApi/advancedApi.kt index 3e380d947362..f1b5db35e7ae 100644 --- a/python/interpreters/src/advancedApi/advancedApi.kt +++ b/python/interpreters/src/advancedApi/advancedApi.kt @@ -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 ExecService.executePythonAdvanced( python: ValidInterpreter, - argsBuilder: suspend ArgsBuilder.() -> Unit = {}, + args: Args, options: ExecOptions = ExecOptions(), processInteractiveHandler: ProcessInteractiveHandler, ): PyResult = - executePythonAdvanced(python.asExecutablePython, argsBuilder, options, processInteractiveHandler) + executePythonAdvanced(python.asExecutablePython, args, options, processInteractiveHandler) /** diff --git a/python/interpreters/src/api.kt b/python/interpreters/src/api.kt index a951f274da60..c2a7a3dcead0 100644 --- a/python/interpreters/src/api.kt +++ b/python/interpreters/src/api.kt @@ -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 = - executePythonAdvanced(python.asExecutablePython, { addArgs(*args.toTypedArray()) }, options, transformerToHandler(procListener, ZeroCodeStdoutTransformer)) \ No newline at end of file + executePythonAdvanced(python.asExecutablePython, Args(*args.toTypedArray()), options, transformerToHandler(procListener, ZeroCodeStdoutTransformer)) \ No newline at end of file diff --git a/python/openapi/src/com/jetbrains/python/errorProcessing/ExecError.kt b/python/openapi/src/com/jetbrains/python/errorProcessing/ExecError.kt index c481e0be4c61..6be3d0302723 100644 --- a/python/openapi/src/com/jetbrains/python/errorProcessing/ExecError.kt +++ b/python/openapi/src/com/jetbrains/python/errorProcessing/ExecError.kt @@ -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 { diff --git a/python/python-exec-service/BUILD.bazel b/python/python-exec-service/BUILD.bazel index 1f54ba9cc750..6f3383c9c3a8 100644 --- a/python/python-exec-service/BUILD.bazel +++ b/python/python-exec-service/BUILD.bazel @@ -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"] ) diff --git a/python/python-exec-service/execService.python/src/advancedApi/advancedApi.kt b/python/python-exec-service/execService.python/src/advancedApi/advancedApi.kt index 7cc7120be17a..b53eb96fa6c6 100644 --- a/python/python-exec-service/execService.python/src/advancedApi/advancedApi.kt +++ b/python/python-exec-service/execService.python/src/advancedApi/advancedApi.kt @@ -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 ExecService.executePythonAdvanced( python: ExecutablePython, - argsBuilder: suspend ArgsBuilder.() -> Unit = {}, + args: Args, options: ExecOptions = ExecOptions(), processInteractiveHandler: ProcessInteractiveHandler, -): PyExecResult = - executeAdvanced(python.binary, { - addArgs(*python.args.toTypedArray()) - argsBuilder() +): PyResult = + 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 ExecService.executeHelperAdvanced( options: ExecOptions = ExecOptions(), procListener: PyProcessListener? = null, processOutputTransformer: ProcessOutputTransformer, -): PyExecResult = executePythonAdvanced(python, { - addLocalFile(PythonHelpersLocator.findPathInHelpers(helper)) - addArgs(*args.toTypedArray()) - -}, options, transformerToHandler(procListener, processOutputTransformer)) +): PyResult = 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. diff --git a/python/python-exec-service/execService.python/src/impl/impl.kt b/python/python-exec-service/execService.python/src/impl/impl.kt index 2f2118cacdc5..cc7fea9d2e32 100644 --- a/python/python-exec-service/execService.python/src/impl/impl.kt +++ b/python/python-exec-service/execService.python/src/impl/impl.kt @@ -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 = 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(null, { r -> + val versionOutput: EelProcessExecutionResult = executePythonAdvanced(python, options = options, args = Args(PYTHON_VERSION_ARG), processInteractiveHandler = transformerToHandler(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 diff --git a/python/python-exec-service/intellij.python.community.execService.iml b/python/python-exec-service/intellij.python.community.execService.iml index 4951ae17dcb7..d3d1e32a76b7 100644 --- a/python/python-exec-service/intellij.python.community.execService.iml +++ b/python/python-exec-service/intellij.python.community.execService.iml @@ -26,5 +26,7 @@ + + \ No newline at end of file diff --git a/python/python-exec-service/src/com/intellij/python/community/execService/ProgressListener.kt b/python/python-exec-service/src/com/intellij/python/community/execService/ProgressListener.kt index b77b72afa106..8833ab148c14 100644 --- a/python/python-exec-service/src/com/intellij/python/community/execService/ProgressListener.kt +++ b/python/python-exec-service/src/com/intellij/python/community/execService/ProgressListener.kt @@ -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 sealed interface ProcessEvent { - data class ProcessStarted @ApiStatus.Internal constructor(val binary: Path, val args: List) : ProcessEvent + data class ProcessStarted @ApiStatus.Internal constructor(val binary: BinaryToExec, val args: List) : ProcessEvent data class ProcessOutput @ApiStatus.Internal constructor(val stream: OutputType, val line: String) : ProcessEvent data class ProcessEnded @ApiStatus.Internal constructor(val exitCode: Int) : ProcessEvent diff --git a/python/python-exec-service/src/com/intellij/python/community/execService/advancedApi.kt b/python/python-exec-service/src/com/intellij/python/community/execService/advancedApi.kt index 09dc4a7fa29d..19a509c39323 100644 --- a/python/python-exec-service/src/com/intellij/python/community/execService/advancedApi.kt +++ b/python/python-exec-service/src/com/intellij/python/community/execService/advancedApi.kt @@ -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 executeAdvanced( - binary: Path, - argsBuilder: suspend ArgsBuilder.() -> Unit = {}, + binary: BinaryToExec, + args: Args, options: ExecOptions = ExecOptions(), processInteractiveHandler: ProcessInteractiveHandler, - ): PyExecResult + ): PyResult } - /** * Message to be displayed to a user in case of process failure. */ @@ -61,7 +57,7 @@ fun interface ProcessInteractiveHandler { * 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, process: EelProcess): Result> + suspend fun getResultFromProcess(binary: BinaryToExec, args: List, process: Process): Result> } @@ -76,18 +72,3 @@ typealias ProcessSemiInteractiveFun = suspend (EelSendChannel, Deferred processSemiInteractiveHandler(pyProcessListener: PyProcessListener? = null, code: ProcessSemiInteractiveFun): ProcessInteractiveHandler = 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) -} \ No newline at end of file diff --git a/python/python-exec-service/src/com/intellij/python/community/execService/api.kt b/python/python-exec-service/src/com/intellij/python/community/execService/api.kt index 891d49be7a2a..d8d172dcf7fe 100644 --- a/python/python-exec-service/src/com/intellij/python/community/execService/api.kt +++ b/python/python-exec-service/src/com/intellij/python/community/execService/api.kt @@ -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 = emptyList(), options: ExecOptions = ExecOptions(), procListener: PyProcessListener? = null, +): PyResult = execGetStdout(BinOnEel(binary), args, options, procListener) + + +/** + * Execute [binary] right directly where it sits + */ +suspend fun ExecService.execGetStdout( + binary: BinaryToExec, + args: List = emptyList(), + options: ExecOptions = ExecOptions(), + procListener: PyProcessListener? = null, ): PyResult = execute( binary = binary, args = args, @@ -59,7 +92,7 @@ suspend fun ExecService.execGetStdout( ): PyResult { 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 { 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 ExecService.execute( - binary: Path, + binary: BinaryToExec, args: List = emptyList(), options: ExecOptions = ExecOptions(), procListener: PyProcessListener? = null, @@ -111,8 +144,8 @@ suspend fun 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 { /** - * @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 = 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(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 + get() = _args.mapNotNull { + when (it) { + is Arg.FileArg -> it.file + is Arg.StringArg -> null + } + } + + internal suspend fun getArgs(mapFileToRemote: suspend (local: Path) -> String): List = + _args.map { + when (it) { + is Arg.StringArg -> it.arg + is Arg.FileArg -> it.generator.generateArg(mapFileToRemote(it.file)) + } + } +} \ No newline at end of file diff --git a/python/python-exec-service/src/com/intellij/python/community/execService/impl/ExecServiceImpl.kt b/python/python-exec-service/src/com/intellij/python/community/execService/impl/ExecServiceImpl.kt index 3216ac93c0eb..3c60a1394431 100644 --- a/python/python-exec-service/src/com/intellij/python/community/execService/impl/ExecServiceImpl.kt +++ b/python/python-exec-service/src/com/intellij/python/community/execService/impl/ExecServiceImpl.kt @@ -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 executeAdvanced(binary: Path, argsBuilder: suspend ArgsBuilder.() -> Unit, options: ExecOptions, processInteractiveHandler: ProcessInteractiveHandler): PyExecResult { - 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 executeAdvanced(binary: BinaryToExec, args: Args, options: ExecOptions, processInteractiveHandler: ProcessInteractiveHandler): PyResult { 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, - val env: Map, - val workingDirectory: Path?, - val description: @Nls String, -) - -@CheckReturnValue -private suspend fun EelExecutableProcess.run(scopeToBound: CoroutineScope): PyExecResult { - 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 { - return ExecError( - exe = Exe.OnEel(exe), +private fun ProcessLauncher.createExecError(messageToUser: @Nls String, errorReason: ExecErrorReason): Result.Failure = + 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 { - 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 { - 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 { fileLogger().warn(message) return failure(this) } - -private class ArgsBuilderImpl(private val eel: EelApi) : ArgsBuilder { - private val _args = CopyOnWriteArrayList() - val args: List = _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) - } -} \ No newline at end of file diff --git a/python/python-exec-service/src/com/intellij/python/community/execService/impl/argImpl.kt b/python/python-exec-service/src/com/intellij/python/community/execService/impl/argImpl.kt new file mode 100644 index 000000000000..bfd775313491 --- /dev/null +++ b/python/python-exec-service/src/com/intellij/python/community/execService/impl/argImpl.kt @@ -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 +} \ No newline at end of file diff --git a/python/python-exec-service/src/com/intellij/python/community/execService/impl/interactiveHandlersImpl.kt b/python/python-exec-service/src/com/intellij/python/community/execService/impl/interactiveHandlersImpl.kt index 7616731e2759..8fae182f43a2 100644 --- a/python/python-exec-service/src/com/intellij/python/community/execService/impl/interactiveHandlersImpl.kt +++ b/python/python-exec-service/src/com/intellij/python/community/execService/impl/interactiveHandlersImpl.kt @@ -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( private val pyProcessListener: PyProcessListener?, private val code: ProcessSemiInteractiveFun, ) : ProcessInteractiveHandler { - override suspend fun getResultFromProcess(binary: Path, args: List, process: EelProcess): Result> = + override suspend fun getResultFromProcess(binary: BinaryToExec, args: List, process: Process): Result> = 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) } diff --git a/python/python-exec-service/src/com/intellij/python/community/execService/impl/processAwaiter.kt b/python/python-exec-service/src/com/intellij/python/community/execService/impl/processAwaiter.kt index 7e9d5b9dae28..0a8c94d68ee8 100644 --- a/python/python-exec-service/src/com/intellij/python/community/execService/impl/processAwaiter.kt +++ b/python/python-exec-service/src/com/intellij/python/community/execService/impl/processAwaiter.kt @@ -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?): EelProcessExecutionResult = +internal suspend fun Process.awaitWithReporting(progressListener: FlowCollector?): 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?): ByteArray = withContext(Dispatchers.IO) { +private suspend fun Process.report(outputType: OutputType, to: FlowCollector?): 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() diff --git a/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/ProcessLauncher.kt b/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/ProcessLauncher.kt new file mode 100644 index 000000000000..11cd55113a2f --- /dev/null +++ b/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/ProcessLauncher.kt @@ -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, + private val processCommands: ProcessCommands, +) { + suspend fun start(): Result = processCommands.start() + suspend fun killAndJoin() { + processCommands.processFunctions.killAndJoin(logger, exeForError.toString()) + } +} + +internal interface ProcessCommands { + suspend fun start(): Result + val processFunctions: ProcessFunctions +} + +internal data class LaunchRequest( + val scopeToBind: CoroutineScope, + val args: Args, + val env: Map, +) \ No newline at end of file diff --git a/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/eel.kt b/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/eel.kt new file mode 100644 index 000000000000..b3f8533fce80 --- /dev/null +++ b/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/eel.kt @@ -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, + private val env: Map, +) : ProcessCommands { + private var eelProcess: EelProcess? = null + + override val processFunctions: ProcessFunctions = ProcessFunctions( + waitForExit = { eelProcess?.exitCode?.await() }, + killProcess = { eelProcess?.kill() } + ) + + + override suspend fun start(): Result { + 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)) + } + } +} diff --git a/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/package-info.java b/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/package-info.java new file mode 100644 index 000000000000..dd0e0bd47aad --- /dev/null +++ b/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/package-info.java @@ -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; \ No newline at end of file diff --git a/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/targets.kt b/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/targets.kt new file mode 100644 index 000000000000..f3f86e7399f0 --- /dev/null +++ b/python/python-exec-service/src/com/intellij/python/community/execService/impl/processLaunchers/targets.kt @@ -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 = 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, + private val env: Map, +) : 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 { + 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 = Result.failure(ExecErrorReason.CantStart(null, localizedMessage)) \ No newline at end of file diff --git a/python/python-exec-service/tests/com/intellij/python/junit5Tests/unit/alsoWin/ExecServiceShowCaseTest.kt b/python/python-exec-service/tests/com/intellij/python/junit5Tests/unit/alsoWin/ExecServiceShowCaseTest.kt index e3f0f42e8cb7..a8c3791962af 100644 --- a/python/python-exec-service/tests/com/intellij/python/junit5Tests/unit/alsoWin/ExecServiceShowCaseTest.kt +++ b/python/python-exec-service/tests/com/intellij/python/junit5Tests/unit/alsoWin/ExecServiceShowCaseTest.kt @@ -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 { _, _, process -> - val stdout = async { - process.stdout.readWholeText() + val output = ExecService().executeAdvanced(BinOnEel(shell), Args(), processInteractiveHandler = ProcessInteractiveHandler { _, _, 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 { channel, exitCode -> + val result = ExecService().executeAdvanced(BinOnEel(shell), Args(), processInteractiveHandler = processSemiInteractiveHandler { 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(progressCapturer) { stdin, _ -> + ExecService().executeAdvanced(BinOnEel(shell), args = Args(), processInteractiveHandler = processSemiInteractiveHandler(progressCapturer) { stdin, _ -> for (string in text) { stdin.sendWholeText("echo $string\n") delay(500) diff --git a/python/python-hatch/src/com/intellij/python/hatch/runtime/HatchRuntime.kt b/python/python-hatch/src/com/intellij/python/hatch/runtime/HatchRuntime.kt index 295ec1a6720a..0ca5f5ba3936 100644 --- a/python/python-hatch/src/com/intellij/python/hatch/runtime/HatchRuntime.kt +++ b/python/python-hatch/src/com/intellij/python/hatch/runtime/HatchRuntime.kt @@ -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 executeInteractive(vararg arguments: String, processSemiInteractiveFun: ProcessSemiInteractiveFun): PyResult { - 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 { @@ -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) diff --git a/python/src/com/jetbrains/python/sdk/PySdkCommandRunner.kt b/python/src/com/jetbrains/python/sdk/PySdkCommandRunner.kt index 684130278520..6ad8a4681687 100644 --- a/python/src/com/jetbrains/python/sdk/PySdkCommandRunner.kt +++ b/python/src/com/jetbrains/python/sdk/PySdkCommandRunner.kt @@ -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 = emptyMap(), vararg args: String, ): PyResult { - 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) } diff --git a/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt b/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt index a22cb65d47ca..a03b2628874f 100644 --- a/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt +++ b/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt @@ -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 } } }