Python tests: separate conda from vanilla python env types for tests.

With this change we can move conda into the separate module to decouple test framework from community.impl

GitOrigin-RevId: 589dc29f39d9d46252681c8f31b8fe482e63595f
This commit is contained in:
Ilya.Kazakevich
2025-02-02 07:05:23 +00:00
committed by intellij-monorepo-bot
parent 88dc070d16
commit 1be9caaac9
12 changed files with 110 additions and 97 deletions
@@ -15,5 +15,6 @@
<orderEntry type="module" module-name="intellij.python.community.impl" />
<orderEntry type="module" module-name="intellij.platform.execution" />
<orderEntry type="library" name="kotlin-reflect" level="project" />
<orderEntry type="module" module-name="intellij.platform.lang.impl" />
</component>
</module>
@@ -1,32 +1,22 @@
// 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.testFramework.testEnv
import com.intellij.execution.processTools.getResultStdout
import com.intellij.execution.target.local.LocalTargetEnvironmentRequest
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.jetbrains.python.PythonBinary
import com.jetbrains.python.packaging.findCondaExecutableRelativeToEnv
import com.jetbrains.python.sdk.conda.TargetEnvironmentRequestCommandExecutor
import com.jetbrains.python.sdk.flavors.PythonSdkFlavor
import com.jetbrains.python.sdk.flavors.conda.PyCondaEnv
import com.jetbrains.python.sdk.flavors.conda.PyCondaEnvIdentity
import com.jetbrains.python.venvReader.VirtualEnvReader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking
import org.jetbrains.annotations.NonNls
import java.nio.file.Path
import java.util.concurrent.ConcurrentHashMap
import kotlin.Result.Companion.failure
/**
* Gradle script installs two types of python: conda and vanilla. Env could be obtained by [getTestEnvironment] which also provides closable
* Gradle script installs two types of python: conda and vanilla. Env could be obtained by [createSdkClosableEnv] which also provides closable
* to clean up after the usage
*/
sealed class PythonType<T : Any>(private val tag: @NonNls String? = null) {
abstract class PythonType<T : Any>(private val tag: @NonNls String) {
private companion object {
val cache: MutableMap<Set<String>, List<Path>> = ConcurrentHashMap()
}
@@ -65,72 +55,23 @@ sealed class PythonType<T : Any>(private val tag: @NonNls String? = null) {
/**
* Returns first (whatever it means) test environment and closable that must be closed after the test
* Returns sdk, (whatever it means) test environment and closable that must be closed after the test
*/
suspend fun getTestEnvironment(vararg additionalTags: @NonNls String): Result<Pair<T, AutoCloseable>> =
getTestEnvironments(*additionalTags).firstOrNull()?.let { Result.success(it) }
suspend fun createSdkClosableEnv(vararg additionalTags: @NonNls String): Result<Triple<Sdk, AutoCloseable, T>> =
getTestEnvironments(*additionalTags).firstOrNull()?.let { (env, closable) ->
Result.success(Triple(createSdkFor(env), closable, env))
}
?: failure(AssertionError("No python found. See ${PyEnvTestSettings::class} class for more info"))
protected abstract suspend fun createSdkFor(t: T): Sdk
protected abstract suspend fun pythonPathToEnvironment(pythonBinary: PythonBinary, envDir: Path): Pair<T, AutoCloseable>
data object VanillaPython3 : PythonType<PythonBinary>() {
// Python is directly executable
override suspend fun pythonPathToEnvironment(pythonBinary: PythonBinary, envDir: Path): Pair<PythonBinary, AutoCloseable> {
val disposable = Disposer.newDisposable("Python tests disposable for VfsRootAccess")
// We might have python installation outside the project root, but we still need to have access to it.
VfsRootAccess.allowRootAccess(disposable, pythonBinary.parent.toString())
return Pair(pythonBinary, AutoCloseable {
Disposer.dispose(disposable)
})
}
}
data object Conda : PythonType<PyCondaEnv>("conda") {
override suspend fun pythonPathToEnvironment(pythonBinary: PythonBinary, envDir: Path): Pair<PyCondaEnv, AutoCloseable> {
// First, find python binary, then calculate conda from it as env stores "conda" as a regular env
val condaPath = findCondaExecutableRelativeToEnv(pythonBinary) ?: error("Conda root $pythonBinary doesn't have conda binary")
// We'll remove then on close
val condaEnvsBeforeTest = getCondaNames(condaPath)
val cleanupCondas = AutoCloseable {
runBlocking(Dispatchers.IO) {
val condasToRemove = getCondaNames(condaPath)
condasToRemove.removeAll(condaEnvsBeforeTest)
for (envName in condasToRemove) {
println("Removing $envName")
for (arg in arrayOf("--name", "-p")) {
val args = arrayOf(condaPath.toString(), "remove", arg, envName, "--all", "-y")
Runtime.getRuntime().exec(args).getResultStdout().getOrElse {
logger<Conda>().warn(it)
}
}
}
}
}
return Pair(PyCondaEnv(PyCondaEnvIdentity.UnnamedEnv(envDir.toString(), isBase = true), condaPath.toString()), cleanupCondas)
}
private suspend fun getCondaNames(condaPath: Path) =
PyCondaEnv.getEnvs(TargetEnvironmentRequestCommandExecutor(LocalTargetEnvironmentRequest()),
condaPath.toString()).getOrThrow()
.map { it.envIdentity.userReadableName }
.toMutableSet()
}
@RequiresBackgroundThread
private fun typeMatchesEnv(env: Path, vararg additionalTags: @NonNls String): Boolean {
val envTags = loadEnvTags(env)
for (badTag in PythonType::class.sealedSubclasses.filterNot { it.isInstance(this) }.map { it.objectInstance!!.tag }) {
if (badTag != null && badTag in envTags) return false
}
return (tag == null || tag in envTags) && additionalTags.all { it in envTags }
return tag in envTags && additionalTags.all { it in envTags }
}
}
@@ -0,0 +1,52 @@
// 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.testFramework.testEnv
import com.intellij.execution.processTools.getResultStdout
import com.intellij.execution.target.local.LocalTargetEnvironmentRequest
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.projectRoots.Sdk
import com.jetbrains.python.PythonBinary
import com.jetbrains.python.packaging.findCondaExecutableRelativeToEnv
import com.jetbrains.python.sdk.conda.TargetEnvironmentRequestCommandExecutor
import com.jetbrains.python.sdk.flavors.conda.PyCondaEnv
import com.jetbrains.python.sdk.flavors.conda.PyCondaEnvIdentity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import java.nio.file.Path
data object TypeConda : PythonType<PyCondaEnv>("conda") {
override suspend fun createSdkFor(env: PyCondaEnv): Sdk =
env.createSdkFromThisEnv(null, emptyList())
override suspend fun pythonPathToEnvironment(pythonBinary: PythonBinary, envDir: Path): Pair<PyCondaEnv, AutoCloseable> {
// First, find python binary, then calculate conda from it as env stores "conda" as a regular env
val condaPath = findCondaExecutableRelativeToEnv(pythonBinary) ?: error("Conda root $pythonBinary doesn't have conda binary")
// We'll remove then on close
val condaEnvsBeforeTest = getCondaNames(condaPath)
val cleanupCondas = AutoCloseable {
runBlocking(Dispatchers.IO) {
val condasToRemove = getCondaNames(condaPath)
condasToRemove.removeAll(condaEnvsBeforeTest)
for (envName in condasToRemove) {
println("Removing $envName")
for (arg in arrayOf("--name", "-p")) {
val args = arrayOf(condaPath.toString(), "remove", arg, envName, "--all", "-y")
Runtime.getRuntime().exec(args).getResultStdout().getOrElse {
logger<TypeConda>().warn(it)
}
}
}
}
}
return Pair(PyCondaEnv(PyCondaEnvIdentity.UnnamedEnv(envDir.toString(), isBase = true), condaPath.toString()), cleanupCondas)
}
private suspend fun getCondaNames(condaPath: Path) =
PyCondaEnv.Companion.getEnvs(TargetEnvironmentRequestCommandExecutor(LocalTargetEnvironmentRequest()),
condaPath.toString()).getOrThrow()
.map { it.envIdentity.userReadableName }
.toMutableSet()
}
@@ -0,0 +1,28 @@
// 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.testFramework.testEnv
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.projectRoots.SdkType
import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess
import com.intellij.testFramework.utils.vfs.refreshAndGetVirtualFile
import com.jetbrains.python.PyNames
import com.jetbrains.python.PythonBinary
import java.nio.file.Path
data object TypeVanillaPython3 : PythonType<PythonBinary>("python3") {
override suspend fun createSdkFor(python: PythonBinary): Sdk =
SdkConfigurationUtil.setupSdk(emptyArray(), python.refreshAndGetVirtualFile(),
SdkType.findByName(PyNames.PYTHON_SDK_ID_NAME)!!, null, null)
// Python is directly executable
override suspend fun pythonPathToEnvironment(pythonBinary: PythonBinary, envDir: Path): Pair<PythonBinary, AutoCloseable> {
val disposable = Disposer.newDisposable("Python tests disposable for VfsRootAccess")
// We might have python installation outside the project root, but we still need to have access to it.
VfsRootAccess.allowRootAccess(disposable, pythonBinary.parent.toString())
return Pair(pythonBinary, AutoCloseable {
Disposer.dispose(disposable)
})
}
}