diff --git a/org.knime.python3.scripting.nodes/python-kernel-tester/PythonKernelTester.py b/org.knime.python3.scripting.nodes/python-kernel-tester/PythonKernelTester.py index 3b9180791..9e027ad26 100644 --- a/org.knime.python3.scripting.nodes/python-kernel-tester/PythonKernelTester.py +++ b/org.knime.python3.scripting.nodes/python-kernel-tester/PythonKernelTester.py @@ -448,7 +448,6 @@ def check_required_modules(self, additional_required_modules=None): self.check_module("traceback") self.check_module("os") self.check_module("pickle") - self.check_module("imp") self.check_module("types") # Non-standard modules. self.check_module("numpy") diff --git a/org.knime.python3.scripting.nodes/src/main/java/org/knime/python3/scripting/nodes/prefs/PythonKernelTester.java b/org.knime.python3.scripting.nodes/src/main/java/org/knime/python3/scripting/nodes/prefs/PythonKernelTester.java index e1276b936..fa19a8a17 100644 --- a/org.knime.python3.scripting.nodes/src/main/java/org/knime/python3/scripting/nodes/prefs/PythonKernelTester.java +++ b/org.knime.python3.scripting.nodes/src/main/java/org/knime/python3/scripting/nodes/prefs/PythonKernelTester.java @@ -103,7 +103,7 @@ private static String getPythonKernelTesterPath() throws IOException { if (url == null) { throw new IOException("Could not locate the python kernel tester script"); } - return FileUtil.getFileFromURL(FileLocator.toFileURL(url)).getAbsolutePath(); + return FileUtil.getFileFromURL(FileLocator.toFileURL(url)).getCanonicalPath(); } private PythonKernelTester() { @@ -149,22 +149,32 @@ private static synchronized PythonKernelTestResult testPythonInstallation(final final var process = runPythonKernelTester(pythonCommand, majorVersion, minimumVersion, additionalRequiredModules, additionalOptionalModules, testLogger); - // Get error output. + // Read stdout and stderr concurrently to avoid a deadlock: if Python fills + // one pipe buffer while Java blocks draining the other, both sides hang. final var errorWriter = new StringWriter(); - try (var err = process.getErrorStream()) { - IOUtils.copy(err, errorWriter, StandardCharsets.UTF_8); + final var outputWriter = new StringWriter(); + final var stderrReader = new Thread(() -> { + try (var err = process.getErrorStream()) { + IOUtils.copy(err, errorWriter, StandardCharsets.UTF_8); + } catch (final IOException e) { + LOGGER.debug("Error reading stderr of Python kernel tester", e); + } + }, "PythonKernelTester-stderr"); + stderrReader.start(); + try (var in = process.getInputStream()) { + IOUtils.copy(in, outputWriter, StandardCharsets.UTF_8); + } + try { + stderrReader.join(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); } + var errorOutput = errorWriter.toString(); if (!errorOutput.isEmpty()) { testLogger.append("Error during execution: " + errorOutput + "\n"); errorOutput = decorateErrorOutputForKnownProblems(errorOutput); } - - // Get regular output. - final var outputWriter = new StringWriter(); - try (var in = process.getInputStream()) { - IOUtils.copy(in, outputWriter, StandardCharsets.UTF_8); - } final String testOutput = outputWriter.toString(); testLogger.append("Raw test output: \n" + testOutput + "\n"); @@ -223,7 +233,7 @@ private static Process runPythonKernelTester(final ExternalProcessProvider pytho } if (!additionalRequiredModules.isEmpty()) { commandArguments.add("-m"); // Flag for additional modules. - additionalOptionalModules.forEach(moduleSpec -> commandArguments.add(moduleSpec.toString())); + additionalRequiredModules.forEach(moduleSpec -> commandArguments.add(moduleSpec.toString())); } if (!additionalOptionalModules.isEmpty()) { commandArguments.add("-o"); diff --git a/org.knime.python3/src/main/java/org/knime/python3/SimplePythonCommand.java b/org.knime.python3/src/main/java/org/knime/python3/SimplePythonCommand.java index 3148e8fa2..574e2d38e 100644 --- a/org.knime.python3/src/main/java/org/knime/python3/SimplePythonCommand.java +++ b/org.knime.python3/src/main/java/org/knime/python3/SimplePythonCommand.java @@ -48,6 +48,8 @@ */ package org.knime.python3; +import java.io.File; +import java.nio.file.Path; import java.util.List; import org.knime.externalprocessprovider.ExternalProcessProvider; @@ -78,4 +80,52 @@ public SimplePythonCommand(final String... commands) { public SimplePythonCommand(final List command) { super(command); } + + /** + * Creates a {@link ProcessBuilder} for this command, prepending the Python executable's conda environment + * directories to {@code PATH}. On Windows, conda/pixi environments require several sub-directories to be on + * {@code PATH} for proper process startup: + * + * Without these, Python 3.12+ fails at startup with a Windows SxS assembly error because VC++ runtime DLLs + * cannot be located. Only directories that actually exist on disk are prepended to avoid polluting {@code PATH} + * for non-conda environments. + */ + @Override + public ProcessBuilder createProcessBuilder() { + final var pb = super.createProcessBuilder(); + final Path executableDir = getExecutablePath().getParent(); + if (executableDir != null) { + final var env = pb.environment(); + // ProcessBuilder.environment() on Windows stores keys case-insensitively but + // preserves the original casing. Find the existing key to avoid duplicates. + final String pathKey = env.keySet().stream() // + .filter(k -> k.equalsIgnoreCase("PATH")) // + .findFirst() // + .orElse("PATH"); + final String existingPath = env.getOrDefault(pathKey, ""); + + // Standard conda/pixi activation paths for Windows, in activation order. + final var dirsToAdd = new StringBuilder(); + for (final String subDir : new String[]{"", "Library\\bin", "Library\\mingw-w64\\bin", + "Library\\usr\\bin", "Scripts"}) { + final var candidate = subDir.isEmpty() ? executableDir : executableDir.resolve(subDir); + if (candidate.toFile().isDirectory()) { + if (dirsToAdd.length() > 0) { + dirsToAdd.append(File.pathSeparator); + } + dirsToAdd.append(candidate); + } + } + if (dirsToAdd.length() > 0) { + env.put(pathKey, dirsToAdd + File.pathSeparator + existingPath); + } + } + return pb; + } }