diff --git a/platform/eel/src/com/intellij/platform/eel/path/EelPath.kt b/platform/eel/src/com/intellij/platform/eel/path/EelPath.kt index 340720523290..8b430d005b7e 100644 --- a/platform/eel/src/com/intellij/platform/eel/path/EelPath.kt +++ b/platform/eel/src/com/intellij/platform/eel/path/EelPath.kt @@ -1,4 +1,4 @@ -// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +// 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.platform.eel.path import com.intellij.platform.eel.EelDescriptor @@ -145,6 +145,9 @@ sealed interface EelPath { fun toDebugString(): String + /** + * @return path in the particular eel, i.e.: `/foo` or `c:\bar` + */ override fun toString(): String @Deprecated("Use EelPlatform instead, will be removed soon") diff --git a/platform/testFramework/junit5/eel/test/params/api/TestApplicationWithEel.kt b/platform/testFramework/junit5/eel/test/params/api/TestApplicationWithEel.kt index 14f683e76f19..c661491fad1a 100644 --- a/platform/testFramework/junit5/eel/test/params/api/TestApplicationWithEel.kt +++ b/platform/testFramework/junit5/eel/test/params/api/TestApplicationWithEel.kt @@ -23,7 +23,10 @@ import org.junit.jupiter.api.extension.ExtendWith * ``` * -Djava.nio.file.spi.DefaultFileSystemProvider=com.intellij.platform.core.nio.fs.MultiRoutingFileSystemProvider * ``` - * Run a test, and failure will report an option name + * Run a test, and failure will report an option name. + * + * You either need to have providers (i.e. `intellij.platform.ijent.testFramework` in a classpath), + * or disable [atLeastOneRemoteEelRequired] */ @TestOnly @Target(AnnotationTarget.CLASS) diff --git a/python/openapi/intellij.python.community.iml b/python/openapi/intellij.python.community.iml index 413ca8fe6625..73e1b56e3354 100644 --- a/python/openapi/intellij.python.community.iml +++ b/python/openapi/intellij.python.community.iml @@ -21,5 +21,6 @@ + \ 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 4620908d167c..804b38aa7efc 100644 --- a/python/openapi/src/com/jetbrains/python/errorProcessing/ExecError.kt +++ b/python/openapi/src/com/jetbrains/python/errorProcessing/ExecError.kt @@ -3,6 +3,7 @@ package com.jetbrains.python.errorProcessing import com.intellij.execution.process.ProcessOutput import com.intellij.openapi.util.NlsContexts +import com.intellij.platform.eel.path.EelPath import com.jetbrains.python.PyCommunityBundle import org.jetbrains.annotations.Nls @@ -10,22 +11,19 @@ import org.jetbrains.annotations.Nls * External process error. */ class ExecError( + val exe: EelPath, /** - * I.e ['python', '-v'] + * I.e ['-v'] */ - val command: Array, + val args: Array, val errorReason: ExecErrorReason, /** * optional message to be displayed to the user: Why did we run this process. I.e "running pip to install package". */ val additionalMessageToUser: @NlsContexts.DialogTitle String? = null, -) : PyError(getExecErrorMessage(command, additionalMessageToUser, errorReason)) { - val exeAndArgs: Pair> = Pair(command[0], command.drop(1).toTypedArray()) - - init { - assert(command.isNotEmpty()) { "Command can't be empty" } - } +) : PyError(getExecErrorMessage(exe.toString(), args, additionalMessageToUser, errorReason)) { + val asCommand: String get() = (arrayOf(exe.toString()) + args).joinToString(" ") } @@ -51,12 +49,13 @@ sealed interface ExecErrorReason { fun ProcessOutput.asExecutionFailed(): ExecErrorReason.UnexpectedProcessTermination = ExecErrorReason.UnexpectedProcessTermination(exitCode, stdout, stderr) -internal fun getExecErrorMessage( - command: Array, +private fun getExecErrorMessage( + exec: String, + args: Array, additionalMessage: @NlsContexts.DialogTitle String?, execErrorReason: ExecErrorReason, ): @Nls String { - val commandLine = command.joinToString(" ") + val commandLine = exec + args.joinToString(" ") return when (val r = execErrorReason) { is ExecErrorReason.CantStart -> { PyCommunityBundle.message("python.execution.cant.start.error", diff --git a/python/openapi/src/com/jetbrains/python/packaging/PyExecutionException.kt b/python/openapi/src/com/jetbrains/python/packaging/PyExecutionException.kt index 82159080bfac..5f2b0fb2aa33 100644 --- a/python/openapi/src/com/jetbrains/python/packaging/PyExecutionException.kt +++ b/python/openapi/src/com/jetbrains/python/packaging/PyExecutionException.kt @@ -4,9 +4,11 @@ package com.jetbrains.python.packaging import com.intellij.execution.ExecutionException import com.intellij.execution.process.ProcessOutput import com.intellij.openapi.util.NlsContexts +import com.intellij.platform.eel.provider.asEelPath import com.jetbrains.python.errorProcessing.* import org.jetbrains.annotations.ApiStatus import java.io.IOException +import kotlin.io.path.Path /** * Wraps [PyError] for cases where [ExecutionException] is used. @@ -29,7 +31,7 @@ class PyExecutionException private constructor( additionalMessageToUser: @NlsContexts.DialogMessage String?, command: String, args: List, - ): PyExecutionException = PyExecutionException(ExecError(arrayOf(command) + args.toTypedArray(), ExecErrorReason.Timeout, additionalMessageToUser)) + ): PyExecutionException = PyExecutionException(ExecError(Path(command).asEelPath(), args.toTypedArray(), ExecErrorReason.Timeout, additionalMessageToUser)) } @@ -63,7 +65,7 @@ class PyExecutionException private constructor( args: List, fixes: List = listOf(), ) : this( - pyError = ExecError(arrayOf(command) + args.toTypedArray(), ExecErrorReason.CantStart(null, startException.localizedMessage), additionalMessage), + pyError = ExecError(Path(command).asEelPath(), args.toTypedArray(), ExecErrorReason.CantStart(null, startException.localizedMessage), additionalMessage), fixes = fixes, ioException = startException) @@ -82,7 +84,7 @@ class PyExecutionException private constructor( output: ProcessOutput, fixes: List = listOf(), ) : this( - pyError = ExecError(arrayOf(command) + args.toTypedArray(), output.asExecutionFailed()), + pyError = ExecError(Path(command).asEelPath(), args.toTypedArray(), output.asExecutionFailed()), fixes = fixes) /** diff --git a/python/python-exec-service/intellij.python.community.execService.iml b/python/python-exec-service/intellij.python.community.execService.iml index 0f1476622f49..2f7636c2b09c 100644 --- a/python/python-exec-service/intellij.python.community.execService.iml +++ b/python/python-exec-service/intellij.python.community.execService.iml @@ -24,5 +24,7 @@ + + \ No newline at end of file diff --git a/python/python-exec-service/resources/messages/PyExecBundle.properties b/python/python-exec-service/resources/messages/PyExecBundle.properties index dbc29177d7a2..2d9963804759 100644 --- a/python/python-exec-service/resources/messages/PyExecBundle.properties +++ b/python/python-exec-service/resources/messages/PyExecBundle.properties @@ -1,6 +1,5 @@ py.exec.defaultName.process=Process py.exec.defaultName.helper=Helper -py.exec.defaultName.command=Command py.exec.start.error={0} Failed to Start: {1} (Code {2}) py.exec.timeout.error={0} Timed out (Run More Than {1}) 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 e4cd8c38f3cc..ddeeab5b9934 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 @@ -5,6 +5,7 @@ import com.intellij.execution.process.ProcessOutput import com.intellij.openapi.util.NlsSafe import com.intellij.platform.eel.EelApi import com.intellij.platform.eel.EelProcess +import com.intellij.platform.eel.provider.asNioPath import com.intellij.python.community.execService.impl.ExecServiceImpl import com.jetbrains.python.PythonBinary import com.jetbrains.python.Result @@ -89,18 +90,21 @@ sealed interface WhatToExec { /** * [binary] (can reside on local or remote Eel, [EelApi] is calculated out of it) */ - data class Binary(val binary: Path) : WhatToExec + data class Binary(val binary: Path) : WhatToExec { + companion object { + /** + * Resolves relative name to the full name or `null` if [relativeBinName] can't be found in the path. + */ + suspend fun fromRelativeName(eel: EelApi, relativeBinName: String): Binary? = + eel.exec.findExeFilesInPath(relativeBinName).firstOrNull()?.let { Binary(it.asNioPath()) } + } + } /** * Execute [helper] on [python]. If [python] resides on remote Eel -- helper is copied there. * Note, that only **one** helper file is copied, not all helpers. */ data class Helper(val python: PythonBinary, val helper: HelperName) : WhatToExec - - /** - * Random command on [eel]. [EelApi] will look for it in the path - */ - data class Command(val eel: EelApi, val command: String) : WhatToExec } /** 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 72ddaadf9d26..b0cd55c38938 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 @@ -3,7 +3,11 @@ package com.intellij.python.community.execService.impl import com.intellij.execution.process.ProcessOutput import com.intellij.openapi.diagnostic.fileLogger -import com.intellij.platform.eel.* +import com.intellij.platform.eel.EelExecApi +import com.intellij.platform.eel.EelProcess +import com.intellij.platform.eel.execute +import com.intellij.platform.eel.getOr +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.* @@ -75,8 +79,7 @@ internal object ExecServiceImpl : ExecService { } private data class EelExecutableProcess( - val eel: EelApi, - val exe: String, + val exe: EelPath, val args: List, val env: Map, val workingDirectory: Path?, @@ -84,8 +87,8 @@ private data class EelExecutableProcess( ) private suspend fun WhatToExec.buildExecutableProcess(args: List, options: ExecOptions): EelExecutableProcess { - val (eel, exe, args) = when (this) { - is WhatToExec.Binary -> Triple(binary.getEelDescriptor().upgrade(), binary.asEelPath().toString(), args) + val (exe, args) = when (this) { + is WhatToExec.Binary -> Pair(binary, args) is WhatToExec.Helper -> { val eel = python.getEelDescriptor().upgrade() val localHelper = PythonHelpersLocator.findPathInHelpers(helper) @@ -94,24 +97,22 @@ private suspend fun WhatToExec.buildExecutableProcess(args: List, option source = localHelper, target = EelPathUtils.TransferTarget.Temporary(eel.descriptor) ).asEelPath().toString() - Triple(eel, python.asEelPath().toString(), listOf(remoteHelper) + args) + Pair(python, listOf(remoteHelper) + args) } - is WhatToExec.Command -> Triple(eel, command, args) } val description = options.processDescription ?: when (this) { is WhatToExec.Binary -> PyExecBundle.message("py.exec.defaultName.process") is WhatToExec.Helper -> PyExecBundle.message("py.exec.defaultName.helper") - is WhatToExec.Command -> PyExecBundle.message("py.exec.defaultName.command") } - return EelExecutableProcess(eel, exe, args, options.env, options.workingDirectory, description) + return EelExecutableProcess(exe.asEelPath(), args, options.env, options.workingDirectory, description) } @CheckReturnValue private suspend fun EelExecutableProcess.run(): Result { val workingDirectory = if (workingDirectory != null && !workingDirectory.isAbsolute) workingDirectory.toRealPath() else workingDirectory - val executionResult = eel.exec.execute(exe) + val executionResult = exe.descriptor.upgrade().exec.execute(exe.toString()) .args(args) .env(env) .workingDirectory(workingDirectory?.asEelPath()).eelIt() @@ -124,7 +125,8 @@ private suspend fun EelExecutableProcess.run(): Result { private fun EelExecutableProcess.failAsCantStart(executeProcessError: EelExecApi.ExecuteProcessError): Result.Failure { return ExecError( - command = arrayOf(exe) + args.toTypedArray(), + exe = exe, + args = args.toTypedArray(), additionalMessageToUser = PyExecBundle.message("py.exec.start.error", description, executeProcessError.message, executeProcessError.errno), errorReason = ExecErrorReason.CantStart(executeProcessError.errno, executeProcessError.message) ).logAndFail() @@ -134,7 +136,8 @@ private suspend fun EelExecutableProcess.killProcessAndFailAsTimeout(eelProcess: eelProcess.kill() return ExecError( - command = arrayOf(exe) + args.toTypedArray(), + exe = exe, + args = args.toTypedArray(), additionalMessageToUser = PyExecBundle.message("py.exec.timeout.error", description, timeout), errorReason = ExecErrorReason.Timeout ).logAndFail() @@ -146,7 +149,8 @@ private fun EelExecutableProcess.failAsExecutionFailed(processOutput: ProcessOut } return ExecError( - command = arrayOf(exe) + args.toTypedArray(), + exe = exe, + args = args.toTypedArray(), additionalMessageToUser = additionalMessage, errorReason = processOutput.asExecutionFailed() ).logAndFail() 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 083e833fdcb5..b9747492a888 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 @@ -2,39 +2,40 @@ 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.localEel -import com.intellij.platform.eel.where +import com.intellij.platform.testFramework.junit5.eel.params.api.EelHolder +import com.intellij.platform.testFramework.junit5.eel.params.api.EelSource +import com.intellij.platform.testFramework.junit5.eel.params.api.TestApplicationWithEel import com.intellij.python.community.execService.ExecService import com.intellij.python.community.execService.WhatToExec import com.intellij.testFramework.common.timeoutRunBlocking -import com.intellij.testFramework.junit5.TestApplication import com.jetbrains.python.Result import com.jetbrains.python.getOrThrow import org.hamcrest.CoreMatchers import org.hamcrest.MatcherAssert.assertThat import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.fail -import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource /** - * How to use [ExecService] + * How to use [ExecService]. + * To exec this test against remote eels, you need `intellij.platform.ijent.testFramework` in classpath (exists on TC) */ -@TestApplication +@TestApplicationWithEel(atLeastOneRemoteEelRequired = false) class ExecServiceShowCaseTest { - private val eel = localEel // TODO: Check other eels + @ParameterizedTest + @EelSource + fun testDataTransformer(eelHolder: EelHolder): Unit = timeoutRunBlocking { + val eel = eelHolder.eel - @Test - fun testDataTransformer(): Unit = timeoutRunBlocking { data class Record(val name: String, val age: Int) - val echoCommand = WhatToExec.Command(eel, "echo") - val args = listOf("Alice,25\nBob,48\n") + val (shell, execArg) = eel.exec.getShell() + val args = listOf(execArg, "echo Alice,25 && echo Bob,48") - val records = ExecService().execute(echoCommand, args) { output -> + val records = ExecService().execute(WhatToExec.Binary(shell.asNioPath()), args) { output -> when { output.exitCode == 123 -> { Result.success(emptyList()) @@ -48,7 +49,7 @@ class ExecServiceShowCaseTest { else -> { val records = output.stdoutLines.map { val (name, age) = it.split(',') - Record(name, age.toInt()) + Record(name, age.trim().toInt()) } Result.success(records) } @@ -62,40 +63,40 @@ class ExecServiceShowCaseTest { } @ParameterizedTest - @ValueSource(booleans = [true, false]) - fun testSunnyDay(useLocalPath: Boolean): Unit = timeoutRunBlocking { + @EelSource + fun testSunnyDay(eelHolder: EelHolder): Unit = timeoutRunBlocking { + val eel = eelHolder.eel val execService = ExecService() val expectedPhrase = "Usage" - val (command, args) = when (eel.platform) { - is EelPlatform.Windows -> Pair("ping.exe", arrayOf("/?")) + val (binaryName, args) = when (eel.platform) { + is EelPlatform.Windows -> { + Pair("ping.exe", arrayOf("/?")) + } is EelPlatform.Posix -> { Pair("sh", arrayOf("-c", "echo $expectedPhrase")) } } - val whatToExec = if (useLocalPath) { - val fullPath = (eel.exec.where(command) ?: error("no $command found on $eel")).asNioPath() - WhatToExec.Binary(fullPath) - } - else { - WhatToExec.Command(eel, command) - } + val whatToExec = WhatToExec.Binary.fromRelativeName(eel, binaryName) ?: error("Can't find $binaryName") val output = execService.execGetStdout(whatToExec, args.toList()).getOrThrow() assertThat("Command doesn't have expected output", output, CoreMatchers.containsString(expectedPhrase)) } - @Test - fun testRainyDay(): Unit = timeoutRunBlocking { + @ParameterizedTest + @EelSource + fun testRainyDay(eelHolder: EelHolder): Unit = timeoutRunBlocking { + val eel = eelHolder.eel + val binary = eel.fs.user.home.asNioPath().resolve("Some_command_that_never_exists_on_any_machine${Math.random()}") val arg = "foo" - val command = WhatToExec.Command(eel, "Some_command_that_never_exists_on_any_machine${Math.random()}") + val command = WhatToExec.Binary(binary) when (val output = ExecService().execGetStdout(command, listOf(arg))) { is Result.Success -> fail("Execution of bad command should lead to an error") is Result.Failure -> { val err = output.error - assertEquals(command.command, err.command[0], "Wrong command reported") - assertEquals("foo", err.command[1], "Wrong args reported") + assertEquals(command.binary, err.exe.asNioPath(), "Wrong command reported") + assertEquals("foo", err.args[0], "Wrong args reported") } } } diff --git a/python/src/com/jetbrains/python/ProcessExecutionErrorDialog.kt b/python/src/com/jetbrains/python/ProcessExecutionErrorDialog.kt index 2be2e4f867fa..4f7e6a53e16d 100644 --- a/python/src/com/jetbrains/python/ProcessExecutionErrorDialog.kt +++ b/python/src/com/jetbrains/python/ProcessExecutionErrorDialog.kt @@ -60,7 +60,7 @@ fun showProcessExecutionErrorDialog( val errorMessageLabel = JBLabel(UIUtil.toHtml(errorMessageText), Messages.getErrorIcon(), SwingConstants.LEFT) val commandOutputTextPane = JTextPane().apply { - val command = execError.command.joinToString(" ") + val command = execError.asCommand when (val err = execError.errorReason) { is ExecErrorReason.CantStart -> { appendProcessOutput(command, err.cantExecProcessError, execError.message, null) diff --git a/python/src/com/jetbrains/python/packaging/PyExecutionExceptionExt.kt b/python/src/com/jetbrains/python/packaging/PyExecutionExceptionExt.kt index 130613aedb99..3c28175b259f 100644 --- a/python/src/com/jetbrains/python/packaging/PyExecutionExceptionExt.kt +++ b/python/src/com/jetbrains/python/packaging/PyExecutionExceptionExt.kt @@ -2,10 +2,12 @@ package com.jetbrains.python.packaging import com.intellij.execution.process.ProcessOutput +import com.intellij.platform.eel.provider.asEelPath import com.jetbrains.python.errorProcessing.ExecError import com.jetbrains.python.errorProcessing.ExecErrorReason import com.jetbrains.python.errorProcessing.MessageError import java.io.IOException +import kotlin.io.path.Path /** * A temporary hack for some outdated code (see usages), do not use in a new code. Stay away from [PyExecutionException] @@ -18,8 +20,7 @@ internal fun PyExecutionException.copyWith(newCommand: String, newArgs: List { - val command = arrayOf(newCommand) + newArgs.toTypedArray() - PyExecutionException(ExecError(command, ExecErrorReason.Timeout, err.additionalMessageToUser)) + PyExecutionException(ExecError(Path(newCommand).asEelPath(), newArgs.toTypedArray(), ExecErrorReason.Timeout, err.additionalMessageToUser)) } is ExecErrorReason.UnexpectedProcessTermination -> { val output = ProcessOutput(reason.stdout, reason.stderr, reason.exitCode, false, false) diff --git a/python/src/com/jetbrains/python/packaging/PyPackageManagerUI.java b/python/src/com/jetbrains/python/packaging/PyPackageManagerUI.java index a452b35df511..84984e16f3c4 100644 --- a/python/src/com/jetbrains/python/packaging/PyPackageManagerUI.java +++ b/python/src/com/jetbrains/python/packaging/PyPackageManagerUI.java @@ -190,7 +190,7 @@ public final class PyPackageManagerUI { if (e instanceof PyExecutionException pyExecutionException) { PyError pyError = pyExecutionException.getPyError(); if (pyError instanceof ExecError execError) { - return Arrays.stream(execError.getExeAndArgs().getSecond()); + return Arrays.stream(execError.getArgs()); } } return null; diff --git a/python/src/com/jetbrains/python/packaging/ui/PyPackageManagementService.java b/python/src/com/jetbrains/python/packaging/ui/PyPackageManagementService.java index cf70179d2e28..1e31fbf7577b 100644 --- a/python/src/com/jetbrains/python/packaging/ui/PyPackageManagementService.java +++ b/python/src/com/jetbrains/python/packaging/ui/PyPackageManagementService.java @@ -329,7 +329,7 @@ public class PyPackageManagementService extends PackageManagementServiceEx { final String stderrCause = findErrorCause(execFailed.getStderr()); final String cause = stdoutCause != null ? stdoutCause : stderrCause; final String message = cause != null ? cause : pyExecEx.getMessage(); - final String command = StringUtil.join(execError.getCommand(), " "); + final String command = execError.getAsCommand(); return new PyPackageInstallationErrorDescription(message, command, stdout.isEmpty() ? execFailed.getStderr() : stdout + "\n" + execFailed.getStderr(), findErrorSolution(pyExecEx, cause, sdk), packageName, sdk); @@ -358,7 +358,8 @@ public class PyPackageManagementService extends PackageManagementServiceEx { } } - if ("pip".equals(e.getCommand()[0]) && sdk != null) { + var fileName = e.getExe().getFileName(); + if (fileName.startsWith("pip") && sdk != null) { return PySdkBundle.message("python.sdk.try.to.run.command.from.system.terminal", sdk.getHomePath()); } } diff --git a/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchUIComponents.kt b/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchUIComponents.kt index e8e3c286bd0c..a3c9928bb45b 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchUIComponents.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchUIComponents.kt @@ -58,7 +58,7 @@ internal sealed class HatchUIError(message: String) : MessageError(message) { class HatchExecutionFailure(execError: ExecError) : HatchUIError( message("sdk.create.custom.hatch.error.execution.failed", - execError.command.joinToString(" ") + execError.asCommand ) ) }