PY-78649: Improve error handling.

There were three problems:

1.
There was `PyExecutionException` with an optional message which was intended to be displayed via dialog using a message as a title.
It breaks an exception contract (code that isn't aware of this particular class should still be able to fetch info, and a message is usually blank).

2.
Moreover, `ErrorSink` wasn't aware of it either and displayed it as a plain text.

3.
`PyExecutionException` didn't distinguish between "process can't be started" and "process died with error code != 0". Those are different cases.

This change:
1. Fixes `PyExecutionException` for 1 and 3.
2. Introduces API in `ErrorSink.kt` to display `PyExecutionException`

GitOrigin-RevId: a8d835afb086b23c73ced15f243d2b27b59dcf82
This commit is contained in:
Ilya.Kazakevich
2025-01-22 21:55:36 +01:00
committed by intellij-monorepo-bot
parent 4b22bc0b72
commit ca2148932f
21 changed files with 373 additions and 67 deletions

View File

@@ -5,6 +5,7 @@
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />

View File

@@ -0,0 +1,2 @@
python.execution.error={0}\nThe following command finished with error: {1}\nOutput: {2}\nError: {3}\Exit code: {4}
python.execution.cant.start.error={0}\nThe following command could not be started: {1}

View File

@@ -0,0 +1,27 @@
// 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
import com.intellij.DynamicBundle
import org.jetbrains.annotations.Nls
import org.jetbrains.annotations.NonNls
import org.jetbrains.annotations.PropertyKey
import java.util.function.Supplier
internal object PyCommunityBundle {
private const val BUNDLE_FQN: @NonNls String = "messages.PyCommunityBundle"
private val BUNDLE = DynamicBundle(PyCommunityBundle::class.java, BUNDLE_FQN)
fun message(
key: @PropertyKey(resourceBundle = BUNDLE_FQN) String,
vararg params: Any
): @Nls String {
return BUNDLE.getMessage(key, *params)
}
fun messagePointer(
key: @PropertyKey(resourceBundle = BUNDLE_FQN) String,
vararg params: Any
): Supplier<String> {
return BUNDLE.getLazyMessage(key, *params)
}
}

View File

@@ -0,0 +1,31 @@
// 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.execution
import com.intellij.execution.process.ProcessOutput
import com.jetbrains.python.packaging.PyExecutionException
/**
* Types of execution error for [PyExecutionFailure]
*/
sealed interface FailureReason {
/**
* A process failed to start, or the code that ought to start it decided not to run it.
* That means the process hasn't been even created.
*/
data object CantStart : FailureReason
/**
* A process started but failed with an error. See [output] for the result
*/
data class ExecutionFailed(val output: ProcessOutput) : FailureReason
}
internal fun copyWith(ex: PyExecutionException, newCommand: String, newArgs: List<String>): PyExecutionException =
when (val err = ex.failureReason) {
FailureReason.CantStart -> {
PyExecutionException(ex.additionalMessage, newCommand, newArgs, ex.fixes)
}
is FailureReason.ExecutionFailed -> {
PyExecutionException(ex.additionalMessage, newCommand, newArgs, err.output, ex.fixes)
}
}

View File

@@ -0,0 +1,21 @@
// 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.execution
import com.intellij.openapi.util.NlsContexts
/**
* Some command can't be executed
*/
interface PyExecutionFailure {
val command: String
val args: List<String>
/**
* optional message to be displayed to the user
*/
val additionalMessage: @NlsContexts.DialogTitle String?
val failureReason: FailureReason
}

View File

@@ -1,63 +1,124 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.packaging;
import com.intellij.execution.ExecutionExceptionWithAttachments;
import com.intellij.execution.ExecutionException;
import com.intellij.execution.process.ProcessOutput;
import com.intellij.openapi.util.NlsContexts.DialogMessage;
import com.intellij.openapi.util.text.StringUtil;
import com.jetbrains.python.PyCommunityBundle;
import com.jetbrains.python.execution.FailureReason;
import com.jetbrains.python.execution.FailureReasonKt;
import com.jetbrains.python.execution.PyExecutionFailure;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Collections;
import java.util.List;
public class PyExecutionException extends ExecutionExceptionWithAttachments {
/**
* Process execution failed.
* There are two cases, see {@link FailureReason}.
* Each constructor represents one or another.
*
* @see FailureReason
*/
public final class PyExecutionException extends ExecutionException implements PyExecutionFailure {
private final @NotNull String myCommand;
private final @NotNull List<String> myArgs;
private final int myExitCode;
private final @NotNull List<? extends PyExecutionFix> myFixes;
private final @DialogMessage @Nullable String myAdditionalMessage;
private final @NotNull FailureReason myError;
public PyExecutionException(@DialogMessage @NotNull String message, @NotNull String command, @NotNull List<String> args) {
this(message, command, args, "", "", 0, Collections.emptyList());
/**
* A process failed to start, {@link FailureReason.CantStart}
*
* @param additionalMessage a process start reason for a user
*/
public PyExecutionException(@DialogMessage @Nullable String additionalMessage,
@NotNull String command,
@NotNull List<String> args) {
this(additionalMessage, command, args, Collections.emptyList());
}
public PyExecutionException(@DialogMessage @NotNull String message,
/**
* A process failed to start, {@link FailureReason.CantStart}
*
* @param additionalMessage a process start reason for a user
*/
public PyExecutionException(@DialogMessage @Nullable String additionalMessage,
@NotNull String command,
@NotNull List<String> args,
@NotNull List<? extends PyExecutionFix> fixes) {
super(PyCommunityBundle.INSTANCE.message("python.execution.cant.start.error",
additionalMessage != null ? additionalMessage : "",
command + " " + StringUtil.join(args, " ")));
myAdditionalMessage = additionalMessage;
myCommand = command;
myArgs = args;
myFixes = fixes;
myError = FailureReason.CantStart.INSTANCE;
}
/**
* A process started, but failed {@link FailureReason.ExecutionFailed}
*
* @param additionalMessage a process start reason for a user
* @param output execution output
*/
public PyExecutionException(@DialogMessage @Nullable String additionalMessage,
@NotNull String command,
@NotNull List<String> args,
@NotNull ProcessOutput output) {
this(message, command, args, output.getStdout(), output.getStderr(), output.getExitCode(), Collections.emptyList());
this(additionalMessage, command, args, output, Collections.emptyList());
}
public PyExecutionException(@DialogMessage @NotNull String message, @NotNull String command, @NotNull List<String> args,
@NotNull String stdout, @NotNull String stderr, int exitCode,
/**
* A process started, but failed {@link FailureReason.ExecutionFailed}
*
* @param additionalMessage a process start reason for a user
* @param output execution output
*/
public PyExecutionException(@DialogMessage @Nullable String additionalMessage,
@NotNull String command,
@NotNull List<String> args,
@NotNull ProcessOutput output,
@NotNull List<? extends PyExecutionFix> fixes) {
super(message, stdout, stderr);
super(PyCommunityBundle.INSTANCE.message("python.execution.error",
additionalMessage != null ? additionalMessage : "",
command + " " + StringUtil.join(args, " "),
output.getStdout(),
output.getStderr(),
output.getExitCode()
));
myAdditionalMessage = additionalMessage;
myCommand = command;
myArgs = args;
myExitCode = exitCode;
myFixes = fixes;
myError = new FailureReason.ExecutionFailed(output);
}
/**
* A process started, but failed {@link FailureReason.ExecutionFailed}
*
* @param additionalMessage a process start reason for a user
*/
public PyExecutionException(@DialogMessage @Nullable String additionalMessage,
@NotNull String command,
@NotNull List<String> args,
@NotNull String stdout,
@NotNull String stderr,
int exitCode,
@NotNull List<? extends PyExecutionFix> fixes) {
this(additionalMessage, command, args, new ProcessOutput(stdout, stderr, exitCode, false, false), fixes);
}
@Override
public String toString() {
final StringBuilder b = new StringBuilder();
b.append("The following command was executed:\n\n");
final String command = getCommand() + " " + StringUtil.join(getArgs(), " ");
b.append(command);
b.append("\n\n");
b.append("The exit code: ").append(myExitCode).append("\n");
b.append("The error output of the command:\n\n");
b.append(getStdout());
b.append("\n");
b.append(getStderr());
b.append("\n");
b.append(getMessage());
return b.toString();
}
public @NotNull String getCommand() {
return myCommand;
}
@Override
public @NotNull List<String> getArgs() {
return myArgs;
}
@@ -66,7 +127,30 @@ public class PyExecutionException extends ExecutionExceptionWithAttachments {
return myFixes;
}
@Override
public @Nullable String getAdditionalMessage() {
return myAdditionalMessage;
}
@Override
public @NotNull FailureReason getFailureReason() {
return myError;
}
/**
* @deprecated use {@link #getFailureReason()} and match it as when process failed to start there is no exit code
*/
@Deprecated(forRemoval = true)
public int getExitCode() {
return myExitCode;
if (getFailureReason() instanceof FailureReason.ExecutionFailed executionFailed) {
return executionFailed.getOutput().getExitCode();
}
return -1;
}
@ApiStatus.Internal
@NotNull
public PyExecutionException copyWith(@NotNull String newCommand, @NotNull List<@NotNull String> newArgs) {
return FailureReasonKt.copyWith(this, newCommand, newArgs);
}
}