diff --git a/python/src/com/jetbrains/python/run/PythonRunner.java b/python/src/com/jetbrains/python/run/PythonRunner.java index 621cd5ab1a66..5f096c512188 100644 --- a/python/src/com/jetbrains/python/run/PythonRunner.java +++ b/python/src/com/jetbrains/python/run/PythonRunner.java @@ -1,8 +1,6 @@ // 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.run; -import com.intellij.codeWithMe.ClientId; -import com.intellij.execution.ExecutionException; import com.intellij.execution.ExecutionResult; import com.intellij.execution.configurations.RunProfile; import com.intellij.execution.configurations.RunProfileState; @@ -12,16 +10,18 @@ import com.intellij.execution.runners.AsyncProgramRunner; import com.intellij.execution.runners.DefaultProgramRunnerKt; import com.intellij.execution.runners.ExecutionEnvironment; import com.intellij.execution.ui.RunContentDescriptor; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.application.ModalityState; +import com.intellij.openapi.application.AppUIExecutor; import com.intellij.openapi.fileEditor.FileDocumentManager; import com.intellij.util.concurrency.AppExecutorUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.concurrency.AsyncPromise; +import org.jetbrains.concurrency.CancellablePromise; import org.jetbrains.concurrency.Promise; import org.jetbrains.concurrency.Promises; +import java.util.concurrent.Callable; + public class PythonRunner extends AsyncProgramRunner { @Override public @NotNull String getRunnerId() { @@ -37,12 +37,12 @@ public class PythonRunner extends AsyncProgramRunner { * {@link PythonCommandLineState} inheritors must be ready to be called on any thread, so we can run then on the background thread. * Any other state must be invoked on EDT only */ - private static void execute(@NotNull RunProfileState profileState, @NotNull Runnable runnable) { - var clientIdRunnable = ClientId.decorateRunnable(runnable); + private static CancellablePromise execute(@NotNull RunProfileState profileState, @NotNull Callable supplier) { if (profileState instanceof PythonCommandLineState) { - AppExecutorUtil.getAppExecutorService().execute(clientIdRunnable); - } else { - ApplicationManager.getApplication().invokeAndWait(clientIdRunnable); + return executeInBgt(supplier); + } + else { + return executeInEdt(supplier); } } @@ -58,24 +58,34 @@ public class PythonRunner extends AsyncProgramRunner { FileDocumentManager.getInstance().saveAllDocuments(); - AsyncPromise promise = new AsyncPromise<>(); - execute(state, () -> { - try { - - ExecutionResult executionResult; - if (state instanceof PythonCommandLineState) { - // TODO [cloud-api.python] profile functionality must be applied here: - // - com.jetbrains.django.run.DjangoServerRunConfiguration.patchCommandLineFirst() - host:port is put in user data - executionResult = ((PythonCommandLineState)state).execute(env.getExecutor()); - } - else { - executionResult = state.execute(env.getExecutor(), this); - } - ApplicationManager.getApplication().invokeLater( - () -> promise.setResult(DefaultProgramRunnerKt.showRunContent(executionResult, env)), - ModalityState.any()); + return execute(state, () -> { + ExecutionResult executionResult; + if (state instanceof PythonCommandLineState) { + // TODO [cloud-api.python] profile functionality must be applied here: + // - com.jetbrains.django.run.DjangoServerRunConfiguration.patchCommandLineFirst() - host:port is put in user data + executionResult = ((PythonCommandLineState)state).execute(env.getExecutor()); } - catch (ExecutionException e) { + else { + executionResult = state.execute(env.getExecutor(), this); + } + return executionResult; + }).thenAsync((ExecutionResult executionResult) -> { + return executeInEdt(() -> DefaultProgramRunnerKt.showRunContent(executionResult, env)); + }); + } + + private static CancellablePromise executeInEdt(Callable callable) { + return AppUIExecutor.onUiThread().submit(callable); + } + + private static CancellablePromise executeInBgt(Callable callable) { + AsyncPromise promise = new AsyncPromise<>(); + AppExecutorUtil.getAppExecutorService().execute(() -> { + try { + V result = callable.call(); + promise.setResult(result); + } + catch (Exception e) { promise.setError(e); } }); diff --git a/python/testSrc/com/jetbrains/env/python/run/PyRunTest.kt b/python/testSrc/com/jetbrains/env/python/run/PyRunTest.kt index 31b2d68644a2..eaf689745f8e 100644 --- a/python/testSrc/com/jetbrains/env/python/run/PyRunTest.kt +++ b/python/testSrc/com/jetbrains/env/python/run/PyRunTest.kt @@ -15,12 +15,13 @@ import com.intellij.openapi.application.runInEdt import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.util.Key import com.intellij.openapi.util.Ref -import com.intellij.testFramework.UsefulTestCase.assertSameElements import com.jetbrains.env.PyEnvTestCase import com.jetbrains.env.PyExecutionFixtureTestTask +import com.jetbrains.env.PyTestTask import com.jetbrains.python.run.PythonConfigurationType import com.jetbrains.python.run.PythonRunConfiguration import com.jetbrains.python.run.PythonRunner +import org.assertj.core.api.Assertions.assertThat import org.junit.Assert import org.junit.Test import java.util.concurrent.CountDownLatch @@ -29,10 +30,59 @@ import java.util.concurrent.TimeUnit class PyRunTest : PyEnvTestCase() { @Test fun testInputRedirection() { + test { + val result = runConfiguration { + redirectInput = true + inputFile = "input.txt" + } + assertThat(result.output).hasSameElementsAs(listOf("100500\n")).describedAs("Output should match expected values") + assertThat(result.state).isEqualTo(ProcessState.Finished(0)) + } + } + + @Test + fun testRunSucceedsWhenEnvFileAbsent() { + test { + val result = runConfiguration { + envFile = absent() + } + assertThat(result.state).isEqualTo(ProcessState.NotStarted) + } + } + + // DSL entry-point bound to the test class to access protected helpers + private fun test(block: TestDslBuilder.() -> Unit) { + val builder = TestDslBuilder { runPythonTestWithException(it) } + builder.block() + } +} + +// --- Minimal DSL for tests in this file --- + +private class RunConfigurationDsl { + var envFile: EnvFileSpec = EnvFileSpec.None + var redirectInput: Boolean? = null + var inputFile: String? = null +} + +private sealed interface EnvFileSpec { + data object None : EnvFileSpec + data object Absent : EnvFileSpec +} + +private fun absent(): EnvFileSpec = EnvFileSpec.Absent + +private class TestDslBuilder(private val executor: (PyTestTask) -> Unit) { + fun runConfiguration(block: RunConfigurationDsl.() -> Unit): RunResult { + val runCfg = RunConfigurationDsl().apply { block() } + val output = mutableListOf() + var processState: ProcessState = ProcessState.NotStarted + + // Execute the same scenario as input redirection test, but honoring the DSL val task = object : PyExecutionFixtureTestTask("/runConfig/inputRedirection") { override fun runTestOn(sdkHome: String, existingSdk: Sdk?) { val project = project - val settings = RunManager.getInstance(project).createConfiguration("test", PythonConfigurationType::class.java) + val settings = RunManager.getInstance(project).createConfiguration("test-missing-env", PythonConfigurationType::class.java) val runConfiguration = settings.configuration as PythonRunConfiguration runConfiguration.apply { @@ -40,33 +90,42 @@ class PyRunTest : PyEnvTestCase() { this.sdk = existingSdk scriptName = "inputRedirection.py" workingDirectory = myFixture.tempDirPath - isRedirectInput = true - inputFile = "${myFixture.tempDirPath}/input.txt" + // Input redirection: use DSL value if provided, default to true and input.txt (to keep older tests working) + isRedirectInput = runCfg.redirectInput ?: false + val input = runCfg.inputFile ?: "input.txt" + inputFile = "${myFixture.tempDirPath}/$input" + + when (runCfg.envFile) { + is EnvFileSpec.Absent -> { + // point to a non-existent .env file; run must still succeed + envFilePaths = listOf("${myFixture.tempDirPath}/.env") + } + else -> Unit + } } val executor = DefaultRunExecutor.getRunExecutorInstance() val runner = ProgramRunner.getRunner(executor.id, settings.configuration) as PythonRunner val env = ExecutionEnvironment(executor, runner, settings, project) - var processNotStarted = false val connection = project.messageBus.connect() val latch = CountDownLatch(1) - val output = mutableListOf() val handlerRef = Ref() connection.subscribe(ExecutionManager.EXECUTION_TOPIC, object : ExecutionListener { override fun processStarted(executorId: String, env: ExecutionEnvironment, handler: ProcessHandler) { super.processStarted(executorId, env, handler) + processState = ProcessState.Started handlerRef.set(handler) } override fun processTerminated(executorId: String, env: ExecutionEnvironment, handler: ProcessHandler, exitCode: Int) { super.processTerminated(executorId, env, handler, exitCode) + processState = ProcessState.Finished(exitCode) latch.countDown() } override fun processNotStarted(executorId: String, e: ExecutionEnvironment) { if (executorId == executor.id && e == env) { - processNotStarted = true latch.countDown() } } @@ -82,6 +141,7 @@ class PyRunTest : PyEnvTestCase() { } override fun processTerminated(event: ProcessEvent) { + processState = ProcessState.Finished(event.exitCode) processHandler.removeProcessListener(this) latch.countDown() } @@ -94,13 +154,19 @@ class PyRunTest : PyEnvTestCase() { handlerRef.get()?.destroyProcess() Assert.assertTrue("Test frozen", await) connection.disconnect() - if (processNotStarted) { - Assert.fail("Failed to start the script") - } - assertSameElements("Incorrect output", output, listOf("100500\n")) } } - runPythonTestWithException(task) + executor(task) + return RunResult(output, processState) } -} \ No newline at end of file +} + +private data class RunResult(val output: List, val state: ProcessState) + +// Represent the process state for the executed run configuration +private sealed interface ProcessState { + data object NotStarted : ProcessState + data object Started : ProcessState + data class Finished(val exitCode: Int) : ProcessState +}