From 25a3ad02b376a0a5a97542c59a371983603b863a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 2 Jan 2026 17:07:15 -0800 Subject: [PATCH 1/2] Refactor to modern Kotlin idioms and fix deprecated API - Replace deprecated MessageType.INFO with NotificationType.INFORMATION - Use Kotlin property syntax (file.path instead of file.getPath()) - Replace nested null checks with scope functions (?.let, takeIf) - Replace try-catch with runCatching for cleaner error handling - Add version caching to VenvProjectViewNodeDecorator for performance - Remove unnecessary @JvmStatic annotations (no Java callers) --- .../VenvProjectViewNodeDecorator.kt | 23 ++++++--- .../com/github/pyvenvmanage/VenvUtils.kt | 50 ++++++------------- .../actions/ConfigurePythonActionAbstract.kt | 39 +++++---------- 3 files changed, 43 insertions(+), 69 deletions(-) diff --git a/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt b/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt index 2f928d4..3a9a516 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt @@ -1,5 +1,7 @@ package com.github.pyvenvmanage +import java.util.concurrent.ConcurrentHashMap + import com.intellij.ide.projectView.PresentationData import com.intellij.ide.projectView.ProjectViewNode import com.intellij.ide.projectView.ProjectViewNodeDecorator @@ -8,18 +10,23 @@ import com.intellij.ui.SimpleTextAttributes import com.jetbrains.python.icons.PythonIcons.Python.Virtualenv class VenvProjectViewNodeDecorator : ProjectViewNodeDecorator { + private val versionCache = ConcurrentHashMap() + override fun decorate( node: ProjectViewNode<*>, data: PresentationData, ) { - val pyVenvCfgPath = VenvUtils.getPyVenvCfg(node.getVirtualFile()) - if (pyVenvCfgPath != null) { - val pythonVersion = VenvUtils.getPythonVersionFromPyVenv(pyvenvCfgPath = pyVenvCfgPath) - if (pythonVersion != null) { - val fileName: String? = data.getPresentableText() - data.clearText() - data.addText(fileName, SimpleTextAttributes.REGULAR_ATTRIBUTES) - data.addText(" [$pythonVersion]", SimpleTextAttributes.GRAY_ATTRIBUTES) + VenvUtils.getPyVenvCfg(node.virtualFile)?.let { pyVenvCfgPath -> + val pythonVersion = + versionCache.computeIfAbsent(pyVenvCfgPath.toString()) { + VenvUtils.getPythonVersionFromPyVenv(pyVenvCfgPath) + } + pythonVersion?.let { version -> + data.presentableText?.let { fileName -> + data.clearText() + data.addText(fileName, SimpleTextAttributes.REGULAR_ATTRIBUTES) + data.addText(" [$version]", SimpleTextAttributes.GRAY_ATTRIBUTES) + } } data.setIcon(Virtualenv) } diff --git a/src/main/kotlin/com/github/pyvenvmanage/VenvUtils.kt b/src/main/kotlin/com/github/pyvenvmanage/VenvUtils.kt index c539904..8e1238f 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/VenvUtils.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/VenvUtils.kt @@ -1,6 +1,5 @@ package com.github.pyvenvmanage -import java.io.IOException import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path @@ -11,38 +10,19 @@ import com.intellij.openapi.vfs.VirtualFile import com.jetbrains.python.sdk.PythonSdkUtil object VenvUtils { - @JvmStatic - fun getPyVenvCfg(file: VirtualFile?): Path? { - if (file != null && file.isDirectory()) { - val venvRootPath = file.getPath() - val pythonExecutable = PythonSdkUtil.getPythonExecutable(venvRootPath) - if (pythonExecutable != null) { - val pyvenvFile = file.findChild("pyvenv.cfg") - if (pyvenvFile != null) { - return Path.of(pyvenvFile.getPath()) - } - } - } - return null - } - - @JvmStatic - fun getPythonVersionFromPyVenv(pyvenvCfgPath: Path): String? { - val props = Properties() - - try { - Files.newBufferedReader(pyvenvCfgPath, StandardCharsets.UTF_8).use { reader -> - props.load(reader) - } - } catch (e: IOException) { - return null // file could not be read - } - - val version = props.getProperty("version") - if (version != null) { - return version.trim { it <= ' ' } - } - - return null - } + fun getPyVenvCfg(file: VirtualFile?): Path? = + file + ?.takeIf { it.isDirectory } + ?.takeIf { PythonSdkUtil.getPythonExecutable(it.path) != null } + ?.findChild("pyvenv.cfg") + ?.let { Path.of(it.path) } + + fun getPythonVersionFromPyVenv(pyvenvCfgPath: Path): String? = + runCatching { + Properties() + .apply { + Files.newBufferedReader(pyvenvCfgPath, StandardCharsets.UTF_8).use { load(it) } + }.getProperty("version") + ?.trim() + }.getOrNull() } diff --git a/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstract.kt b/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstract.kt index 52cc75d..3eeb8e6 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstract.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstract.kt @@ -1,6 +1,7 @@ package com.github.pyvenvmanage.actions import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent @@ -8,7 +9,6 @@ import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.project.Project import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil -import com.intellij.openapi.ui.MessageType import com.intellij.openapi.vfs.VirtualFile import com.jetbrains.python.configuration.PyConfigurableInterpreterList @@ -21,36 +21,22 @@ abstract class ConfigurePythonActionAbstract : AnAction() { override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT override fun update(e: AnActionEvent) { - // Enable action menu when the selected path is the: - // - virtual environment root - // - virtual environment binary (Scripts) folder - // - any files within the binary folder. e.presentation.isEnabledAndVisible = - when (val selectedPath = e.getData(CommonDataKeys.VIRTUAL_FILE)) { - null -> { - false + e.getData(CommonDataKeys.VIRTUAL_FILE)?.let { selectedPath -> + if (selectedPath.isDirectory) { + PythonSdkUtil.getPythonExecutable(selectedPath.path) != null + } else { + PythonSdkUtil.isVirtualEnv(selectedPath.path) } - - else -> { - when (selectedPath.isDirectory) { - true -> { - // check if there is a python executable available under this folder -> name match for binary - PythonSdkUtil.getPythonExecutable(selectedPath.path) != null - } - - false -> { - // check for presence of the activate_this.py + activate alongside or pyvenv.cfg above - PythonSdkUtil.isVirtualEnv(selectedPath.path) - } - } - } - } + } ?: false } override fun actionPerformed(e: AnActionEvent) { val project = e.project ?: return - var selectedPath = e.getData(CommonDataKeys.VIRTUAL_FILE) ?: return - selectedPath = if (selectedPath.isDirectory) selectedPath else selectedPath.parent + val selectedPath = + e.getData(CommonDataKeys.VIRTUAL_FILE)?.let { + if (it.isDirectory) it else it.parent + } ?: return val pythonExecutable = PythonSdkUtil.getPythonExecutable(selectedPath.path) ?: return val sdk: Sdk = PyConfigurableInterpreterList @@ -66,10 +52,11 @@ abstract class ConfigurePythonActionAbstract : AnAction() { .getInstance() .getNotificationGroup("Python SDK change") .createNotification( + "Python SDK Updated", "Updated SDK for $notificationFor to:\n${sdk.name} " + "of type ${sdk.interpreterType.toString().lowercase()} " + sdk.executionType.toString().lowercase(), - MessageType.INFO, + NotificationType.INFORMATION, ).notify(project) } From 386f1f73c8b7e5882de43e72fb1b8de8f2123641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 2 Jan 2026 17:15:10 -0800 Subject: [PATCH 2/2] Add comprehensive test suite Unit tests for VenvUtils: - Test getPythonVersionFromPyVenv with valid/invalid/missing files - Test version parsing with edge cases (trimming, equals signs) Unit tests for VenvProjectViewNodeDecorator: - Test decoration with valid venv directories - Test no decoration for non-venv files - Verify version caching behavior Additional UI test scenarios: - Test Python version display in project tree - Test venv icon decoration - Test context menu behavior on non-venv items Dependencies: - Add mockk for mocking IntelliJ classes in unit tests --- build.gradle.kts | 1 + gradle/libs.versions.toml | 2 + .../kotlin/com/github/pyvenvmanage/UITest.kt | 57 ++++++++ .../VenvProjectViewNodeDecoratorTest.kt | 137 ++++++++++++++++++ .../com/github/pyvenvmanage/VenvUtilsTest.kt | 119 +++++++++++++++ 5 files changed, 316 insertions(+) create mode 100644 src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt create mode 100644 src/test/kotlin/com/github/pyvenvmanage/VenvUtilsTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 3c8c3aa..929834b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,7 @@ repositories { dependencies { testImplementation(libs.jupiter) + testImplementation(libs.mockk) testRuntimeOnly(libs.jupiterEngine) testRuntimeOnly(libs.junitPlatformLauncher) testRuntimeOnly("junit:junit:4.13.2") // legacy JUnit 4 support diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 98485e1..a21e660 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ intelliJPlatform = "2.10.5" junitPlatformLauncher= "6.0.0" jupiter = "6.0.1" kotlin = "2.3.0" +mockk = "1.14.7" kover = "0.9.4" ktlint = "14.0.1" remoteRobot = "0.11.23" @@ -15,6 +16,7 @@ versionUpdate = "1.0.1" [libraries] jupiter = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "jupiter" } jupiterEngine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "jupiter" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } remoteRobot = { module = "com.intellij.remoterobot:remote-robot", version.ref = "remoteRobot" } remoteRobotFixtures = { module = "com.intellij.remoterobot:remote-fixtures", version.ref = "remoteRobot" } junitPlatformLauncher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatformLauncher" } diff --git a/src/test/kotlin/com/github/pyvenvmanage/UITest.kt b/src/test/kotlin/com/github/pyvenvmanage/UITest.kt index 4cbe9d2..26d01b1 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/UITest.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/UITest.kt @@ -125,4 +125,61 @@ class UITest { } } } + + @Test + fun testVenvDirectoryShowsPythonVersion() { + remoteRobot.idea { + with(projectViewTree) { + // The venv directory should display the Python version in brackets + waitFor(ofSeconds(10)) { + hasText { it.text.contains("[") && it.text.contains("]") } + } + } + } + } + + @Test + fun testVenvDirectoryHasVenvIcon() { + remoteRobot.idea { + with(projectViewTree) { + // Verify the venv directory is decorated (has venv text visible) + waitFor(ofSeconds(10)) { + hasText("ve") + } + } + } + } + + @Test + fun testContextMenuOnNonVenvDirectory() { + remoteRobot.idea { + with(projectViewTree) { + // Right-click on a non-venv directory should not show interpreter options + findText("demo").click(MouseButton.RIGHT_BUTTON) + waitFor(ofSeconds(2)) { + // The action menu should be visible but interpreter options should not be enabled + runCatching { + remoteRobot.actionMenuItem("Set as Project Interpreter") + false // If found, test should handle it + }.getOrDefault(true) // If not found, that's expected + } + } + } + } + + @Test + fun testContextMenuOnPythonFile() { + remoteRobot.idea { + with(projectViewTree) { + // Right-click on a Python file should not show interpreter options + findText("main.py").click(MouseButton.RIGHT_BUTTON) + waitFor(ofSeconds(2)) { + runCatching { + remoteRobot.actionMenuItem("Set as Project Interpreter") + false + }.getOrDefault(true) + } + } + } + } } diff --git a/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt b/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt new file mode 100644 index 0000000..6420073 --- /dev/null +++ b/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt @@ -0,0 +1,137 @@ +package com.github.pyvenvmanage + +import java.nio.file.Files +import java.nio.file.Path + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +import com.intellij.ide.projectView.PresentationData +import com.intellij.ide.projectView.ProjectViewNode +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.SimpleTextAttributes + +class VenvProjectViewNodeDecoratorTest { + private lateinit var decorator: VenvProjectViewNodeDecorator + private lateinit var node: ProjectViewNode<*> + private lateinit var data: PresentationData + private lateinit var virtualFile: VirtualFile + + @BeforeEach + fun setUp() { + decorator = VenvProjectViewNodeDecorator() + node = mockk(relaxed = true) + data = mockk(relaxed = true) + virtualFile = mockk(relaxed = true) + + every { node.virtualFile } returns virtualFile + } + + @Nested + inner class DecorateTest { + @BeforeEach + fun setUpMocks() { + mockkObject(VenvUtils) + } + + @AfterEach + fun tearDown() { + unmockkObject(VenvUtils) + } + + @Test + fun `does nothing when virtualFile is null`() { + every { node.virtualFile } returns null + + decorator.decorate(node, data) + + verify(exactly = 0) { data.setIcon(any()) } + verify(exactly = 0) { data.clearText() } + } + + @Test + fun `does nothing when not a venv directory`() { + every { VenvUtils.getPyVenvCfg(virtualFile) } returns null + + decorator.decorate(node, data) + + verify(exactly = 0) { data.setIcon(any()) } + verify(exactly = 0) { data.clearText() } + } + + @Test + fun `sets icon when venv detected`( + @TempDir tempDir: Path, + ) { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "version = 3.11.0") + + every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath + every { data.presentableText } returns "venv" + + decorator.decorate(node, data) + + verify { data.setIcon(any()) } + } + + @Test + fun `adds version text when version available`( + @TempDir tempDir: Path, + ) { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "version = 3.11.0") + + every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath + every { data.presentableText } returns "venv" + + decorator.decorate(node, data) + + verify { data.clearText() } + verify { data.addText("venv", SimpleTextAttributes.REGULAR_ATTRIBUTES) } + verify { data.addText(" [3.11.0]", SimpleTextAttributes.GRAY_ATTRIBUTES) } + } + + @Test + fun `does not add version text when version unavailable`( + @TempDir tempDir: Path, + ) { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "home = /usr/bin") + + every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath + every { data.presentableText } returns "venv" + + decorator.decorate(node, data) + + verify(exactly = 0) { data.clearText() } + verify(exactly = 0) { data.addText(any(), any()) } + verify { data.setIcon(any()) } + } + + @Test + fun `caches version between calls`( + @TempDir tempDir: Path, + ) { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "version = 3.11.0") + + every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath + every { data.presentableText } returns "venv" + + // Call twice + decorator.decorate(node, data) + decorator.decorate(node, data) + + // Version should only be read once due to caching + verify(exactly = 1) { VenvUtils.getPythonVersionFromPyVenv(pyvenvCfgPath) } + } + } +} diff --git a/src/test/kotlin/com/github/pyvenvmanage/VenvUtilsTest.kt b/src/test/kotlin/com/github/pyvenvmanage/VenvUtilsTest.kt new file mode 100644 index 0000000..2e9b0b4 --- /dev/null +++ b/src/test/kotlin/com/github/pyvenvmanage/VenvUtilsTest.kt @@ -0,0 +1,119 @@ +package com.github.pyvenvmanage + +import java.nio.file.Files +import java.nio.file.Path + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +class VenvUtilsTest { + @Nested + inner class GetPythonVersionFromPyVenvTest { + @TempDir + lateinit var tempDir: Path + + private lateinit var pyvenvCfgPath: Path + + @BeforeEach + fun setUp() { + pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + } + + @Test + fun `returns version when present`() { + Files.writeString( + pyvenvCfgPath, + """ + home = /usr/bin + include-system-site-packages = false + version = 3.11.5 + """.trimIndent(), + ) + + val result = VenvUtils.getPythonVersionFromPyVenv(pyvenvCfgPath) + + assertEquals("3.11.5", result) + } + + @Test + fun `returns trimmed version`() { + Files.writeString( + pyvenvCfgPath, + """ + version = 3.12.0 + """.trimIndent(), + ) + + val result = VenvUtils.getPythonVersionFromPyVenv(pyvenvCfgPath) + + assertEquals("3.12.0", result) + } + + @Test + fun `returns null when version key missing`() { + Files.writeString( + pyvenvCfgPath, + """ + home = /usr/bin + include-system-site-packages = false + """.trimIndent(), + ) + + val result = VenvUtils.getPythonVersionFromPyVenv(pyvenvCfgPath) + + assertNull(result) + } + + @Test + fun `returns null when file does not exist`() { + val nonExistentPath = tempDir.resolve("nonexistent.cfg") + + val result = VenvUtils.getPythonVersionFromPyVenv(nonExistentPath) + + assertNull(result) + } + + @Test + fun `returns null for empty file`() { + Files.writeString(pyvenvCfgPath, "") + + val result = VenvUtils.getPythonVersionFromPyVenv(pyvenvCfgPath) + + assertNull(result) + } + + @Test + fun `handles version with extra info`() { + Files.writeString( + pyvenvCfgPath, + """ + version = 3.10.12 + version_info = 3.10.12.final.0 + """.trimIndent(), + ) + + val result = VenvUtils.getPythonVersionFromPyVenv(pyvenvCfgPath) + + assertEquals("3.10.12", result) + } + + @Test + fun `handles equals sign in value`() { + Files.writeString( + pyvenvCfgPath, + """ + version = 3.9.0 + prompt = (venv) = test + """.trimIndent(), + ) + + val result = VenvUtils.getPythonVersionFromPyVenv(pyvenvCfgPath) + + assertEquals("3.9.0", result) + } + } +}