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

PY-78762: Activate conda in Powershell.

We used to use `Invoke-Expression` but then migrated to `&`.

However, a conda activation script is a command (code block), not a file, so we need to use `Invoke-Expression` as '&' doesn't support it.

We check if a file exists, and if it does -- we use '&' which is safe and fast. We use `Invoke-Expression` otherwise.

Merge-request: IJ-MR-158505
Merged-by: Ilya Kazakevich <ilya.kazakevich@jetbrains.com>

PY-78762 (partially): Conda activation for PS cleanup:

1. Use a conda path from SDK
2. report error using echo


Merge-request: IJ-MR-160629
Merged-by: Ilya Kazakevich <ilya.kazakevich@jetbrains.com>

GitOrigin-RevId: b16bd0b1babb1ea3b685daa5697426418356d089
This commit is contained in:
Ilya Kazakevich
2025-04-23 14:10:26 +00:00
committed by intellij-monorepo-bot
parent 4828769449
commit 056bc17f28
8 changed files with 199 additions and 50 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

@@ -16,8 +16,12 @@ Get-ChildItem env:_INTELLIJ_FORCE_PREPEND_* | ForEach-Object {
}
# `JEDITERM_SOURCE` is executed in its own scope now. That means, it can only run code, and export env vars. It can't export PS variables.
# It might be better to source it. See MSDN for the difference between "Call operator &" and "Script scope and dot sourcing"
if (($Env:JEDITERM_SOURCE -ne $null) -and (Test-Path $Env:JEDITERM_SOURCE)) {
& $Env:JEDITERM_SOURCE
if ($Env:JEDITERM_SOURCE -ne $null) {
if (Test-Path "$Env:JEDITERM_SOURCE" -ErrorAction SilentlyContinue) {
& "$Env:JEDITERM_SOURCE"
} else { # If file doesn't exist it might be a script
Invoke-Expression "$Env:JEDITERM_SOURCE"
}
Remove-Item "env:JEDITERM_SOURCE"
}

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

@@ -13,5 +13,7 @@
<orderEntry type="module" module-name="intellij.python.community.testFramework.testEnv.conda" scope="TEST" />
<orderEntry type="library" scope="TEST" name="JUnit5" level="project" />
<orderEntry type="library" scope="TEST" name="jetbrains-annotations" level="project" />
<orderEntry type="module" module-name="intellij.platform.execution" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.core" scope="TEST" />
</component>
</module>

View File

@@ -0,0 +1,30 @@
// 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.python.community.junit5Tests.framework.conda
import com.intellij.execution.processTools.getResultStdoutStr
import com.jetbrains.python.psi.LanguageLevel
import com.jetbrains.python.sdk.flavors.conda.NewCondaEnvRequest
import com.jetbrains.python.sdk.flavors.conda.PyCondaCommand
import com.jetbrains.python.sdk.flavors.conda.PyCondaEnv
import com.jetbrains.python.sdk.flavors.conda.PyCondaEnvIdentity
import org.jetbrains.annotations.ApiStatus
import java.nio.file.Path
import kotlin.io.path.pathString
@ApiStatus.Internal
/**
* Create conda env in [pathToCreateNewEnvIn] using [existingEnv] as a base
*/
suspend fun createCondaEnv(
existingEnv: PyCondaEnv,
pathToCreateNewEnvIn: Path,
): PyCondaEnv {
val process = PyCondaEnv.createEnv(
PyCondaCommand(existingEnv.fullCondaPathOnTarget, null),
NewCondaEnvRequest.EmptyUnnamedEnv(LanguageLevel.PYTHON311, pathToCreateNewEnvIn.pathString)
).getOrThrow()
process.getResultStdoutStr().getOrThrow()
val env = PyCondaEnv(PyCondaEnvIdentity.UnnamedEnv(pathToCreateNewEnvIn.pathString, false), existingEnv.fullCondaPathOnTarget)
return env
}

View File

@@ -25,5 +25,8 @@
<orderEntry type="library" scope="TEST" name="hamcrest" level="project" />
<orderEntry type="module" module-name="intellij.python.community.testFramework.testEnv" scope="TEST" />
<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
@@ -14,12 +15,12 @@ import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.util.SystemInfo
import com.intellij.openapi.util.text.StringUtil
import com.intellij.openapi.vfs.VirtualFile
import com.jetbrains.python.packaging.PyCondaPackageService
import com.jetbrains.python.run.findActivateScript
import com.jetbrains.python.sdk.PySdkUtil
import com.jetbrains.python.sdk.PythonSdkAdditionalData
import com.jetbrains.python.sdk.PythonSdkUtil
import com.jetbrains.python.sdk.flavors.conda.CondaEnvSdkFlavor
import com.jetbrains.python.sdk.flavors.conda.PyCondaFlavorData
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.plugins.terminal.LocalTerminalCustomizer
import org.jetbrains.plugins.terminal.TerminalOptionsProvider
import java.io.File
@@ -31,18 +32,28 @@ 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
if ((sdk.sdkAdditionalData as? PythonSdkAdditionalData)?.flavor is CondaEnvSdkFlavor) {
val condaData = (sdk.sdkAdditionalData as? PythonSdkAdditionalData)?.flavorAndData?.data as? PyCondaFlavorData
if (condaData != null) {
// Activate conda
val condaPath = PyCondaPackageService.getCondaExecutable(sdk.homePath)?.let { Path(it) }
return if (condaPath != null && condaPath.exists() && condaPath.isExecutable()) {
val condaPath = Path(condaData.env.fullCondaPathOnTarget)
return if (condaPath.exists() && condaPath.isExecutable()) {
getCondaActivationCommand(condaPath, sdkHomePath)
}
else {
logger<PyVirtualEnvTerminalCustomizer>().warn("Can't find $condaPath, will not activate conda")
PyTerminalBundle.message("powershell.conda.not.activated", "conda")
val message = PyTerminalBundle.message("powershell.conda.not.activated", "conda")
"echo '$message'"
}
}
@@ -72,10 +83,12 @@ class PyVirtualEnvTerminalCustomizer : LocalTerminalCustomizer() {
""".trimIndent()
}
override fun customizeCommandAndEnvironment(project: Project,
workingDirectory: String?,
command: Array<out String>,
envs: MutableMap<String, String>): Array<out String> {
override fun customizeCommandAndEnvironment(
project: Project,
workingDirectory: String?,
command: Array<out String>,
envs: MutableMap<String, String>,
): Array<out String> {
var sdkByDirectory: Sdk? = null
if (workingDirectory != null) {
runReadAction {
@@ -99,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")
}
}
}
}
@@ -120,6 +138,7 @@ class PyVirtualEnvTerminalCustomizer : LocalTerminalCustomizer() {
}
}
logger.debug("Running ${command.joinToString(" ")} with ${envs.entries.joinToString("\n")}")
return command
}
@@ -158,6 +177,7 @@ class PyVirtualEnvTerminalCustomizer : LocalTerminalCustomizer() {
}
@ApiStatus.Internal
class SettingsState {
var virtualEnvActivate: Boolean = true
}

View File

@@ -2,44 +2,62 @@
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
import com.intellij.platform.eel.provider.utils.readWholeText
import com.intellij.platform.eel.provider.utils.sendWholeText
import com.intellij.python.community.impl.venv.tests.pyVenvFixture
import com.intellij.python.junit5Tests.framework.env.PyEnvTestCase
import com.intellij.python.community.junit5Tests.framework.conda.CondaEnv
import com.intellij.python.community.junit5Tests.framework.conda.PyEnvTestCaseWithConda
import com.intellij.python.community.junit5Tests.framework.conda.createCondaEnv
import com.intellij.python.junit5Tests.framework.env.pySdkFixture
import com.intellij.python.terminal.PyVirtualEnvTerminalCustomizer
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.junit5.fixture.moduleFixture
import com.intellij.testFramework.junit5.fixture.projectFixture
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
import org.jetbrains.plugins.terminal.runner.LocalShellIntegrationInjector
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.Test
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.Disabled
import org.junit.jupiter.api.io.TempDir
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
*/
@PyEnvTestCase
@PyEnvTestCaseWithConda
@Disabled
class PyVirtualEnvTerminalCustomizerTest {
private val projectFixture = projectFixture()
private val tempDirFixture = tempPathFixture(prefix = "some dir with spaces")
@@ -52,15 +70,63 @@ class PyVirtualEnvTerminalCustomizerTest {
moduleFixture = moduleFixture
)
private var sdkToDelete: Sdk? = null
private val powerShell =
PathEnvironmentVariableUtil.findInPath("powershell.exe")?.toPath()
?: Path((System.getenv("SystemRoot") ?: "c:\\windows"), "system32", "WindowsPowerShell", "v1.0", "powershell.exe")
@AfterEach
fun tearDown(): Unit = timeoutRunBlocking {
sdkToDelete?.let { sdk ->
edtWriteAction {
ProjectJdkTable.getInstance().removeJdk(sdk)
}
}
}
@EnabledOnOs(value = [OS.WINDOWS])
@Test
fun powershellActivationTest(): Unit = timeoutRunBlocking(10.minutes) {
val pythonBinary = VirtualEnvReader.Instance.findPythonInPythonRoot(tempDirFixture.get())!!
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 = venvPath.resolve("some path with spaces")
val sdk = createCondaEnv(condaEnv, envDir).createSdkFromThisEnv(null, emptyList())
sdkToDelete = sdk
sdk.persist()
ModuleRootModificationUtil.setModuleSdk(moduleFixture.get(), sdk)
Pair(Path(sdk.homePath!!), envDir.toRealPath().pathString)
}
else {
val venv = VirtualEnvReader.Instance.findPythonInPythonRoot(tempDirFixture.get())!!
Pair(venv, tempDirFixture.get().name)
}
// binary might be like ~8.3, we need to expand it as venv might report both
val pythonBinaryReal = try {
@@ -69,34 +135,49 @@ class PyVirtualEnvTerminalCustomizerTest {
catch (_: IOException) {
pythonBinary
}
val shellOptions = getShellStartupOptions()
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)))
val vendDirName = tempDirFixture.get().name
assertThat("There must be a line with ($vendDirName)", output, hasItem(containsString("($vendDirName)")))
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()
}
@@ -107,19 +188,19 @@ class PyVirtualEnvTerminalCustomizerTest {
}
}
private fun getShellStartupOptions(): ShellStartupOptions {
private fun getShellStartupOptions(workDir: Path, shellType: ShellType): ShellStartupOptions {
val sut = PyVirtualEnvTerminalCustomizer()
val env = mutableMapOf<String, String>()
val command = sut.customizeCommandAndEnvironment(
projectFixture.get(),
tempDirFixture.get().pathString,
arrayOf(powerShell.pathString),
workDir.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)
}