PY-71499: Spaces in a conda env path break terminal action on **nix.

To activate conda on **nix, we provide a path to `active` script and a path to the env directory.

Terminal shell integration scripts then "source" `activate` providing an env path as an argument.

The latter is called `JEDITERM_SOURCE` env var, the former is `JEDITERM_SOURCE_ARGS`.

The problem is those integration scripts treat `JEDITERM_SOURCE_ARGS` as a list, so they use shell magic to break it into several arguments, so `/path/foo bar/` effectively presented as `['/path/foo', 'bar/']`.

To fix it, we introduce the ` JEDITERM_SOURCE_SINGLE_ARG ` key which means "do not explode argument."


(cherry picked from commit a0a7c7a7bc8789078dd6cf109f4fd4386c9b7da6)

IJ-MR-159065

GitOrigin-RevId: 0c44ce6c43b292f30a094ac79d5f5d7e8935935c
This commit is contained in:
Ilya.Kazakevich
2025-04-01 17:20:08 +02:00
committed by intellij-monorepo-bot
parent 3352209090
commit 5e4396a97c
5 changed files with 107 additions and 36 deletions

View File

@@ -71,8 +71,12 @@ then
fi
if [ -n "${JEDITERM_SOURCE-}" ]
then
source "$JEDITERM_SOURCE" ${JEDITERM_SOURCE_ARGS-}
then # JEDITERM_SOURCE_ARGS might be either list of args or one arg depending on JEDITERM_SOURCE_SINGLE_ARG
if [ -n "${JEDITERM_SOURCE_SINGLE_ARG}" ]; then
source "$JEDITERM_SOURCE" "${JEDITERM_SOURCE_ARGS}"
else
source "$JEDITERM_SOURCE" ${JEDITERM_SOURCE_ARGS-}
fi
unset JEDITERM_SOURCE
unset JEDITERM_SOURCE_ARGS
fi

View File

@@ -2,9 +2,14 @@
function __jetbrains_intellij_update_environment() {
if [[ -n "${JEDITERM_SOURCE:-}" ]]; then
builtin source -- "$JEDITERM_SOURCE" ${=JEDITERM_SOURCE_ARGS:-}
if [[ -n "${JEDITERM_SOURCE_SINGLE_ARG}" ]]; then
# JEDITERM_SOURCE_ARGS might be either list of args or one arg depending on JEDITERM_SOURCE_SINGLE_ARG
builtin source -- "$JEDITERM_SOURCE" "${JEDITERM_SOURCE_ARGS}"
else
builtin source -- "$JEDITERM_SOURCE" ${=JEDITERM_SOURCE_ARGS:-}
fi
fi
builtin unset JEDITERM_SOURCE JEDITERM_SOURCE_ARGS
# Enable native zsh options to make coding easier.
builtin emulate -L zsh

View File

@@ -27,5 +27,6 @@
<orderEntry type="module" module-name="intellij.python.community.impl.venv" scope="TEST" />
<orderEntry type="module" module-name="intellij.python.community.junit5Tests.framework.conda" scope="TEST" />
<orderEntry type="library" scope="TEST" name="JUnit5Params" level="project" />
<orderEntry type="library" scope="TEST" name="JUnit5Pioneer" level="project" />
</component>
</module>

View File

@@ -7,6 +7,7 @@ import com.intellij.openapi.components.Service
import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.options.UnnamedConfigurable
import com.intellij.openapi.project.Project
@@ -31,7 +32,15 @@ import kotlin.io.path.exists
import kotlin.io.path.isExecutable
import kotlin.io.path.name
class PyVirtualEnvTerminalCustomizer : LocalTerminalCustomizer() {
private companion object {
const val JEDITERM_SOURCE = "JEDITERM_SOURCE"
const val JEDITERM_SOURCE_ARGS = "JEDITERM_SOURCE_ARGS"
const val JEDITERM_SOURCE_SINGLE_ARG = "JEDITERM_SOURCE_SINGLE_ARG"
val logger = fileLogger()
}
private fun generatePowerShellActivateScript(sdk: Sdk, sdkHomePath: VirtualFile): String? {
// TODO: This should be migrated to Targets API: each target provides terminal
val condaData = (sdk.sdkAdditionalData as? PythonSdkAdditionalData)?.flavorAndData?.data as? PyCondaFlavorData
@@ -103,13 +112,18 @@ class PyVirtualEnvTerminalCustomizer : LocalTerminalCustomizer() {
val shellName = Path(shellPath).name
if (isPowerShell(shellName)) {
generatePowerShellActivateScript(sdk, sdkHomePath)?.let {
envs.put("JEDITERM_SOURCE", it)
envs.put(JEDITERM_SOURCE, it)
}
}
else {
findActivateScript(sdkHomePath.path, shellPath)?.let { activate ->
envs.put("JEDITERM_SOURCE", activate.first)
envs.put("JEDITERM_SOURCE_ARGS", activate.second ?: "")
envs.put(JEDITERM_SOURCE, activate.first)
envs.put(JEDITERM_SOURCE_ARGS, activate.second ?: "")
// **nix shell integration scripts split arguments;
// since a path may contain spaces, we do not want it to be split into several arguments.
if (activate.second != null) {
envs.put(JEDITERM_SOURCE_SINGLE_ARG, "1")
}
}
}
}
@@ -124,6 +138,7 @@ class PyVirtualEnvTerminalCustomizer : LocalTerminalCustomizer() {
}
}
logger.debug("Running ${command.joinToString(" ")} with ${envs.entries.joinToString("\n")}")
return command
}
@@ -162,6 +177,7 @@ class PyVirtualEnvTerminalCustomizer : LocalTerminalCustomizer() {
}
@ApiStatus.Internal
internal class SettingsState {
var virtualEnvActivate: Boolean = true

View File

@@ -3,9 +3,12 @@ package com.intellij.python.junit5Tests.env.terminal
import com.intellij.execution.configurations.PathEnvironmentVariableUtil
import com.intellij.openapi.application.edtWriteAction
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.projectRoots.ProjectJdkTable
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.roots.ModuleRootModificationUtil
import com.intellij.openapi.util.SystemInfo
import com.intellij.platform.eel.EelExecApi
import com.intellij.platform.eel.getOrThrow
import com.intellij.platform.eel.provider.localEel
@@ -24,8 +27,10 @@ import com.intellij.testFramework.junit5.fixture.tempPathFixture
import com.jetbrains.python.sdk.flavors.conda.PyCondaEnv
import com.jetbrains.python.sdk.persist
import com.jetbrains.python.venvReader.VirtualEnvReader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.hamcrest.CoreMatchers.*
import org.hamcrest.MatcherAssert.assertThat
import org.jetbrains.plugins.terminal.ShellStartupOptions
@@ -34,19 +39,18 @@ import org.jetbrains.plugins.terminal.util.ShellIntegration
import org.jetbrains.plugins.terminal.util.ShellType
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.condition.EnabledOnOs
import org.junit.jupiter.api.condition.OS
import org.junit.jupiter.api.Assumptions
import org.junit.jupiter.api.io.TempDir
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.junitpioneer.jupiter.cartesian.CartesianTest
import java.io.IOException
import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.io.path.exists
import kotlin.io.path.isExecutable
import kotlin.io.path.name
import kotlin.io.path.pathString
import kotlin.time.Duration.Companion.minutes
private const val WHERE_EXE = "where.exe"
/**
* Run `powershell.exe` with a venv activation script and make sure there are no errors and python is correct
@@ -75,17 +79,42 @@ class PyVirtualEnvTerminalCustomizerTest {
}
}
private val powerShell =
PathEnvironmentVariableUtil.findInPath("powershell.exe")?.toPath()
?: Path((System.getenv("SystemRoot") ?: "c:\\windows"), "system32", "WindowsPowerShell", "v1.0", "powershell.exe")
@EnabledOnOs(value = [OS.WINDOWS])
@ParameterizedTest
@ValueSource(booleans = [true, false])
fun powershellActivationTest(useConda: Boolean, @CondaEnv condaEnv: PyCondaEnv, @TempDir path: Path): Unit = timeoutRunBlocking(10.minutes) {
private fun getShellPath(shellType: ShellType): Path = when (shellType) {
ShellType.POWERSHELL -> PathEnvironmentVariableUtil.findInPath("powershell.exe")?.toPath()
?: Path((System.getenv("SystemRoot")
?: "c:\\windows"), "system32", "WindowsPowerShell", "v1.0", "powershell.exe")
ShellType.FISH, ShellType.BASH, ShellType.ZSH -> Path("/usr/bin/${shellType.name.lowercase()}")
}
@CartesianTest
fun shellActivationTest(
@CartesianTest.Values(booleans = [true, false]) useConda: Boolean,
@CartesianTest.Enum shellType: ShellType,
@CondaEnv condaEnv: PyCondaEnv,
@TempDir venvPath: Path,
): Unit = timeoutRunBlocking(10.minutes) {
when (shellType) {
ShellType.POWERSHELL -> Assumptions.assumeTrue(SystemInfo.isWindows, "PowerShell is Windows only")
ShellType.FISH -> Assumptions.abort("Fish terminal activation isn't supported")
ShellType.ZSH, ShellType.BASH -> Assumptions.assumeFalse(SystemInfo.isWindows, "Unix shells do not work on Windows")
}
val shellPath = getShellPath(shellType)
if (!withContext(Dispatchers.IO) { shellPath.exists() && shellPath.isExecutable() }) {
when (shellType) {
ShellType.ZSH -> Assumptions.assumeFalse(SystemInfo.isMac, "Zsh is mandatory on mac")
ShellType.BASH -> error("$shellPath not found")
ShellType.FISH -> error("Fish must be ignored")
ShellType.POWERSHELL -> error("Powershell is mandatory on Windows")
}
}
val (pythonBinary, venvDirName) =
if (useConda) {
val envDir = path.resolve("some path with spaces")
val envDir = venvPath.resolve("some path with spaces")
val sdk = createCondaEnv(condaEnv, envDir).createSdkFromThisEnv(null, emptyList())
sdkToDelete = sdk
sdk.persist()
@@ -104,33 +133,49 @@ class PyVirtualEnvTerminalCustomizerTest {
catch (_: IOException) {
pythonBinary
}
val shellOptions = getShellStartupOptions(pythonBinary.parent)
val shellOptions = getShellStartupOptions(pythonBinary.parent, shellType)
val command = shellOptions.shellCommand!!
val exe = command[0]
val args = if (command.size == 1) emptyList() else command.subList(1, command.size)
val execOptions = EelExecApi.ExecuteProcessOptions.Builder(exe)
.args(args)
.env(shellOptions.envVariables)
.env(shellOptions.envVariables + mapOf(Pair("TERM", "dumb")))
// Unix shells do not activate with out tty
.ptyOrStdErrSettings(if (SystemInfo.isWindows) null else EelExecApi.Pty(100, 100, true))
.build()
val process = localEel.exec.execute(execOptions).getOrThrow()
try {
val stderr = launch {
val error = process.stderr.readWholeText().getOrThrow()
Assertions.assertTrue(error.isEmpty(), "Unexpected text in stderr: $error")
val stderr = async {
process.stderr.readWholeText().getOrThrow()
}
val stdout = async {
process.stdout.readWholeText().getOrThrow().split("\n").map { it.trim() }
val separator = if (SystemInfo.isWindows) "\n" else "\r\n"
process.stdout.readWholeText().getOrThrow().split(separator).map { it.trim() }
}
val where = PathEnvironmentVariableUtil.findInPath(WHERE_EXE)?.toString() ?: WHERE_EXE
process.stdin.sendWholeText("$where python\nexit\n").getOrThrow()
stderr.join()
val output = stdout.await()
// tool -- where.exe Windows, "type(1)" **nix
// "$TOOL python" returns $PREFIX [path-to-python] $POSTFIX
val (locateTool, prefix, postfix) = if (SystemInfo.isWindows) {
Triple(PathEnvironmentVariableUtil.findInPath("where.exe")?.toString() ?: "where.exe", "", "")
}
else {
// zsh wraps text in ''
val quot = if (shellType == ShellType.ZSH) "'" else ""
Triple("type", "python is $quot", quot)
}
process.stdin.sendWholeText("$locateTool python\nexit\n").getOrThrow()
val error = stderr.await()
assertThat("We ran `$where`, so we there should be python path", output,
anyOf(hasItem(pythonBinary.pathString), hasItem(pythonBinaryReal.pathString)))
assertThat("There must be a line with ($venvDirName)", output, hasItem(containsString("($venvDirName)")))
Assertions.assertTrue(error.isEmpty(), "Unexpected text in stderr: $error")
val output = stdout.await()
fileLogger().info("Output was $output")
assertThat("We ran `$locateTool`, so we there should be python path", output,
anyOf(hasItem(prefix + pythonBinary.pathString + postfix), hasItem(prefix + pythonBinaryReal.pathString + postfix)))
if (SystemInfo.isWindows) {
assertThat("There must be a line with ($venvDirName)", output, hasItem(containsString("($venvDirName)")))
}
process.exitCode.await()
}
@@ -141,19 +186,19 @@ class PyVirtualEnvTerminalCustomizerTest {
}
}
private fun getShellStartupOptions(workDir: Path): ShellStartupOptions {
private fun getShellStartupOptions(workDir: Path, shellType: ShellType): ShellStartupOptions {
val sut = PyVirtualEnvTerminalCustomizer()
val env = mutableMapOf<String, String>()
val command = sut.customizeCommandAndEnvironment(
projectFixture.get(),
workDir.pathString,
arrayOf(powerShell.pathString),
arrayOf(getShellPath(shellType).pathString),
env)
val options = ShellStartupOptions.Builder()
.envVariables(env)
.shellCommand(command.toList())
.shellIntegration(ShellIntegration(ShellType.POWERSHELL, null))
.shellIntegration(ShellIntegration(shellType, null))
.build()
return LocalShellIntegrationInjector.injectShellIntegration(options, false, false)
}