PY-86778 [python] Migrate Python test environments from local builds to prebuilt standalone distributions

Replace Gradle-based local Python compilation with prebuilt standalone distributions
downloaded from JetBrains cache redirector. Introduces provider-based architecture
for test environment management with improved JUnit4/JUnit5 integration.

Key changes:
- Remove setup-test-environment Gradle module and build infrastructure
- Introduce python-test-env modules (core, common, plain, conda, uv, junit4, junit5)
- Implement PyEnvironmentProvider/PyEnvironmentSpec abstractions
- Add JUnit5 annotations (@RequiresPoetry, @RequiresUv, @RunOnEnvironments)
- Implement caching system with PyEnvDownloadCache and variant-specific directories
- Predefined environments like: VANILLA_2_7, VANILLA_3_11, VANILLA_3_12, VANILLA_3_13

Benefits:
- Faster test environment provisioning (no compilation required)
- Consistent cross-platform behavior with identical Python builds
- Improved reliability by eliminating platform-specific build failures
- Better test framework integration with parameterized environment testing

GitOrigin-RevId: a44957c3014f9e1fc7dbec347a7e245675f999c1
This commit is contained in:
Anton Efimchuk
2026-01-10 00:33:44 +00:00
committed by intellij-monorepo-bot
parent 00eafdc932
commit 4f5b07293f
168 changed files with 9804 additions and 2036 deletions
@@ -56,7 +56,6 @@ jvm_library(
"//python/python-sdk:sdk_test_lib",
"//python/junit5Tests-framework:community-junit5Tests-framework_test_lib",
"//python/python-exec-service/execService.python",
"//python/python-exec-service/execService.python:execService.python_test_lib",
]
)
### auto-generated section `build intellij.python.community.services.internal.impl` end
@@ -1,30 +0,0 @@
// 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.junit5Tests.env.services.internal.impl
import com.intellij.python.community.services.internal.impl.VanillaPythonWithPythonInfoImpl
import com.intellij.python.junit5Tests.framework.env.PyEnvTestCase
import com.intellij.python.junit5Tests.framework.env.PythonBinaryPath
import com.intellij.python.junit5Tests.randomBinary
import com.jetbrains.python.PythonBinary
import com.jetbrains.python.Result
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
@PyEnvTestCase
class PythonWithLanguageLevelImplTest {
@Test
fun testRainyDay(): Unit = runBlocking {
when (val r = VanillaPythonWithPythonInfoImpl.createByPythonBinary(randomBinary)) {
is Result.Failure -> Unit
is Result.Success -> fail("Unexpected success ${r.result}")
}
}
@Test
fun testSunnyDay(@PythonBinaryPath pythonBinary: PythonBinary): Unit = runBlocking {
val python = VanillaPythonWithPythonInfoImpl.createByPythonBinary(pythonBinary).orThrow()
assertEquals(pythonBinary, python.pythonBinary, "Wrong python binary")
assertTrue(python.pythonInfo.languageLevel.isPy3K, "Wrong python version")
}
}
-1
View File
@@ -48,7 +48,6 @@ jvm_library(
"//platform/testFramework/junit5:junit5_test_lib",
"//libraries/hamcrest",
"//python/python-exec-service/execService.python",
"//python/python-exec-service/execService.python:execService.python_test_lib",
"//platform/diagnostic",
"//platform/util",
]
@@ -71,13 +71,10 @@ jvm_library(
"//python/junit5Tests-framework:community-junit5Tests-framework_test_lib",
"//python/installer",
"//python/python-venv:community-impl-venv",
"//python/python-venv:community-impl-venv_test_lib",
"//python/setup-test-environment:community-testFramework-testEnv",
"//platform/testFramework",
"//platform/testFramework:testFramework_test_lib",
"//python/python-psi-impl:psi-impl",
"//python/python-exec-service/execService.python",
"//python/python-exec-service/execService.python:execService.python_test_lib",
],
exports = [
"//python/services/shared",
@@ -30,7 +30,6 @@
<orderEntry type="module" module-name="intellij.python.community.junit5Tests.framework" scope="TEST" />
<orderEntry type="module" module-name="intellij.python.community.impl.installer" />
<orderEntry type="module" module-name="intellij.python.community.impl.venv" />
<orderEntry type="module" module-name="intellij.python.community.testFramework.testEnv" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.testFramework" scope="TEST" />
<orderEntry type="module" module-name="intellij.python.psi.impl" />
<orderEntry type="module" module-name="intellij.python.community.execService.python" />
@@ -45,7 +45,7 @@ internal suspend fun getCacheTimeout(): Duration? =
@State(name = "SystemPythonService", storages = [Storage("systemPythonService.xml", roamingType = RoamingType.LOCAL)],
allowLoadInTests = true)
@Internal
internal class SystemPythonServiceImpl(scope: CoroutineScope) : SystemPythonService, SimplePersistentStateComponent<MyServiceState>(MyServiceState()) {
class SystemPythonServiceImpl(scope: CoroutineScope) : SystemPythonService, SimplePersistentStateComponent<MyServiceState>(MyServiceState()) {
private val findPythonsMutex = Mutex()
private val _cacheImpl: CompletableDeferred<Cache<EelDescriptor, SystemPython>?> = CompletableDeferred()
private suspend fun cache() = _cacheImpl.await()
@@ -4,9 +4,6 @@
<plugin id="com.intellij.modules.python"/>
</dependencies>
<extensions defaultExtensionNs="com.intellij">
<applicationInitializedListener implementation="com.intellij.python.junit5Tests.env.systemPython.SystemPythonRootsFixer"/>
</extensions>
<extensions defaultExtensionNs="Pythonid">
<systemPythonProvider implementation="com.intellij.python.junit5Tests.env.systemPython.impl.EnvTestPythonProvider"/>
<applicationInitializedListener implementation="com.intellij.python.junit5Tests.env.systemPython.impl.SystemPythonRootsFixer"/>
</extensions>
</idea-plugin>
@@ -1,14 +1,8 @@
<idea-plugin>
<dependencies>
<module name="intellij.platform.testFramework.junit5"/>
<module name="intellij.python.community.junit5Tests.framework._test"/>
<module name="intellij.python.community.services.systemPython"/>
</dependencies>
<extensions defaultExtensionNs="com.intellij">
<applicationInitializedListener implementation="com.intellij.python.junit5Tests.env.systemPython.SystemPythonRootsFixer"/>
</extensions>
<extensions defaultExtensionNs="Pythonid">
<systemPythonProvider implementation="com.intellij.python.junit5Tests.env.systemPython.impl.EnvTestPythonProvider"/>
<applicationInitializedListener implementation="com.intellij.python.junit5Tests.env.systemPython.impl.SystemPythonRootsFixer"/>
</extensions>
</idea-plugin>
@@ -1,20 +0,0 @@
// 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.junit5Tests.env.systemPython
import com.intellij.python.community.services.systemPython.SystemPythonService
import com.intellij.python.junit5Tests.framework.env.PyEnvTestCase
import com.intellij.testFramework.common.timeoutRunBlocking
import com.jetbrains.python.getOrThrow
import com.jetbrains.python.psi.LanguageLevel
import org.junit.jupiter.api.Test
@PyEnvTestCase
class Py27Test {
@Test
fun testPy27(): Unit = timeoutRunBlocking {
val testEnvironments = SystemPythonService().findSystemPythons()
val python27 = testEnvironments.firstOrNull { it.pythonInfo.languageLevel == LanguageLevel.PYTHON27 }
?: error("No 2.7 found in $testEnvironments")
SystemPythonService().registerSystemPython(python27.pythonBinary).getOrThrow()
}
}
@@ -1,112 +0,0 @@
// 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.junit5Tests.env.systemPython
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.platform.eel.EelApi
import com.intellij.platform.eel.ExecuteProcessException
import com.intellij.platform.eel.ThrowsChecked
import com.intellij.platform.eel.provider.getEelDescriptor
import com.intellij.platform.eel.provider.toEelApi
import com.intellij.platform.eel.provider.utils.readWholeText
import com.intellij.platform.eel.spawnProcess
import com.intellij.python.community.services.systemPython.SystemPythonProvider
import com.intellij.python.community.services.systemPython.SystemPythonService
import com.intellij.python.community.services.systemPython.SystemPythonServiceImpl
import com.intellij.python.junit5Tests.assertFail
import com.intellij.python.junit5Tests.framework.env.PyEnvTestCase
import com.intellij.python.junit5Tests.framework.env.PythonBinaryPath
import com.intellij.python.junit5Tests.randomBinary
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.junit5.RegistryKey
import com.intellij.testFramework.junit5.TestDisposable
import com.intellij.testFramework.registerExtension
import com.jetbrains.python.PyToolUIInfo
import com.jetbrains.python.PythonBinary
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.MessageError
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.sdk.flavors.PythonSdkFlavor
import kotlinx.coroutines.async
import org.hamcrest.MatcherAssert
import org.hamcrest.Matchers
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import kotlin.io.path.pathString
import kotlin.time.Duration.Companion.minutes
@PyEnvTestCase
class SystemPythonServiceShowCaseTest {
@ThrowsChecked(ExecuteProcessException::class)
@Test
fun testListPythons(): Unit = timeoutRunBlocking(10.minutes) {
for (systemPython in SystemPythonService().findSystemPythons(forceRefresh = true)) {
fileLogger().info("Python found: $systemPython")
val eelApi = systemPython.pythonBinary.getEelDescriptor().toEelApi()
val process = eelApi.exec.spawnProcess(systemPython.pythonBinary.pathString, "--version").eelIt()
val output = async {
(if (systemPython.pythonInfo.languageLevel.isPy3K) process.stdout else process.stderr).readWholeText()
}
Assertions.assertTrue(process.exitCode.await() == 0)
val versionString = PythonSdkFlavor.getLanguageLevelFromVersionStringStaticSafe(output.await())!!
Assertions.assertEquals(systemPython.pythonInfo.languageLevel, versionString, "Wrong version")
}
}
@Test
fun testCustomPythonRainyDay(): Unit = timeoutRunBlocking(10.minutes) {
SystemPythonService().registerSystemPython(randomBinary).assertFail()
}
@Test
fun testRegister(@PythonBinaryPath path: PythonBinary): Unit = timeoutRunBlocking(10.minutes) {
val sut = SystemPythonService()
repeat(10) {
sut.registerSystemPython(path).orThrow()
}
val pythons = sut.findSystemPythons(forceRefresh = true).map { it.pythonBinary }
MatcherAssert.assertThat("No registered python", pythons, Matchers.hasItem(path))
Assertions.assertEquals(pythons.distinct().size, pythons.size, "Duplicates found")
}
@Test
fun testRefresh(@TestDisposable disposable: Disposable): Unit = timeoutRunBlocking(10.minutes) {
val provider = CountingTestProvider(Result.failure(MessageError("...")))
val sut = SystemPythonService()
// Warm up cache before registering test provider
sut.findSystemPythons()
ApplicationManager.getApplication().registerExtension(SystemPythonProvider.EP, provider, disposable)
repeat(10) {
sut.findSystemPythons()
}
Assertions.assertTrue(provider.calls == 0, "Provider should not be called while cache is valid")
sut.findSystemPythons(forceRefresh = true)
Assertions.assertTrue(provider.calls >= 1, "Provider should be called after force refresh")
}
@RegistryKey("python.system.refresh.minutes", "0")
@Test
fun testDisableCache(@TestDisposable disposable: Disposable): Unit = timeoutRunBlocking(10.minutes) {
val timesToRepeat = 5
val provider = CountingTestProvider(Result.success(emptySet()))
val sut = SystemPythonServiceImpl(this)
ApplicationManager.getApplication().registerExtension(SystemPythonProvider.EP, provider, disposable)
repeat(timesToRepeat) {
sut.findSystemPythons()
}
Assertions.assertTrue(provider.calls == timesToRepeat)
}
private class CountingTestProvider(
private val result: PyResult<Set<PythonBinary>>,
override val uiCustomization: PyToolUIInfo? = null,
) : SystemPythonProvider {
var calls: Int = 0
override suspend fun findSystemPythons(eelApi: EelApi): PyResult<Set<PythonBinary>> {
calls++
return result
}
}
}
@@ -1,63 +0,0 @@
// 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.junit5Tests.env.systemPython.impl
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.platform.eel.EelApi
import com.intellij.python.community.impl.venv.createVenv
import com.intellij.python.community.services.systemPython.SystemPythonProvider
import com.intellij.python.community.services.systemPython.SystemPythonService
import com.intellij.python.junit5Tests.framework.env.PyEnvTestCase
import com.intellij.python.junit5Tests.framework.env.PythonBinaryPath
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.junit5.TestDisposable
import com.intellij.testFramework.registerExtension
import com.jetbrains.python.PyToolUIInfo
import com.jetbrains.python.PythonBinary
import com.jetbrains.python.Result
import com.jetbrains.python.getOrThrow
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
@PyEnvTestCase
class EnvProviderTest {
@Test
fun testPythonProvider(@PythonBinaryPath python: PythonBinary): Unit = timeoutRunBlocking {
val systemPythons = SystemPythonService().findSystemPythons()
val systemPythonBinaries = systemPythons.map { it.pythonBinary }
assertThat("No env python registered", systemPythonBinaries, Matchers.hasItem(python))
if (systemPythons.size > 1) {
val best = systemPythons.first()
for (python in systemPythons.subList(1, systemPythonBinaries.size)) {
assertTrue(python.pythonInfo.languageLevel <= best.pythonInfo.languageLevel, "$best is the first, bust worse than $python")
}
}
}
@Test
fun testProviderWithUi(
@TestDisposable disposable: Disposable,
@PythonBinaryPath python: PythonBinary,
@TempDir venvDir: Path,
): Unit = timeoutRunBlocking {
val venvPython = createVenv(python, venvDir).getOrThrow()
val ui = PyToolUIInfo("myui")
val provider = InlineTestProvider(setOf(venvPython), ui)
ApplicationManager.getApplication().registerExtension(SystemPythonProvider.EP, provider, disposable)
val python = SystemPythonService().findSystemPythons(forceRefresh = true).first { it.pythonBinary == venvPython }
assertTrue(ui == python.ui, "Wrong UI")
}
private class InlineTestProvider(
private val pythons: Set<PythonBinary>,
override val uiCustomization: PyToolUIInfo?,
) : SystemPythonProvider {
override suspend fun findSystemPythons(eelApi: EelApi) = Result.success(pythons)
}
}
@@ -1,41 +0,0 @@
// 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.junit5Tests.env.systemPython.impl
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.util.Disposer
import com.intellij.platform.eel.EelApi
import com.intellij.platform.eel.provider.localEel
import com.intellij.python.community.services.systemPython.SystemPythonProvider
import com.intellij.python.community.testFramework.testEnv.TypeVanillaPython
import com.intellij.python.community.testFramework.testEnv.TypeVanillaPython3
import com.jetbrains.python.PythonBinary
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.PyResult
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.toSet
/**
* Register tests pythons as system pythons
*/
internal class EnvTestPythonProvider : SystemPythonProvider {
override suspend fun findSystemPythons(eelApi: EelApi): PyResult<Set<PythonBinary>> {
var pythons = emptySet<PythonBinary>()
if (eelApi == localEel) {
// Add Py27 temporary to test Py27
// It is perfectly valid not to find any python because some tests might run without a python and still have this module on a class-path
pythons = merge(TypeVanillaPython3.getTestEnvironments(), TypeVanillaPython2.getTestEnvironments())
.map { (python, closeable) ->
Disposer.register(ApplicationManager.getApplication()) {
closeable.close()
}
python
}.toSet()
}
return Result.success(pythons)
}
}
private object TypeVanillaPython2 : TypeVanillaPython("python2.7")
@@ -1,18 +1,20 @@
// 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.junit5Tests.env.systemPython
package com.intellij.python.junit5Tests.env.systemPython.impl
import com.intellij.ide.ApplicationInitializedListener
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess
import com.intellij.python.community.services.systemPython.SystemPythonService
import org.jetbrains.annotations.TestOnly
import kotlin.io.path.pathString
@TestOnly
internal class SystemPythonRootsFixer : ApplicationInitializedListener {
override suspend fun execute() {
val disposable = ApplicationManager.getApplication()
val pythonDirs = SystemPythonService()
.findSystemPythons()
.map { it.pythonBinary.parent.pathString }
.map { it.pythonBinary.toRealPath().parent.pathString }
.toTypedArray()
VfsRootAccess.allowRootAccess(disposable, *pythonDirs)
}
@@ -1,5 +0,0 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
@ApiStatus.Internal
package com.intellij.python.junit5Tests.env.systemPython.impl;
import org.jetbrains.annotations.ApiStatus;