diff --git a/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt b/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt index 3a9a516..62a4557 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt @@ -1,7 +1,5 @@ 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 @@ -10,17 +8,12 @@ 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, ) { VenvUtils.getPyVenvCfg(node.virtualFile)?.let { pyVenvCfgPath -> - val pythonVersion = - versionCache.computeIfAbsent(pyVenvCfgPath.toString()) { - VenvUtils.getPythonVersionFromPyVenv(pyVenvCfgPath) - } + val pythonVersion = VenvVersionCache.getInstance().getVersion(pyVenvCfgPath.toString()) pythonVersion?.let { version -> data.presentableText?.let { fileName -> data.clearText() diff --git a/src/main/kotlin/com/github/pyvenvmanage/VenvVersionCache.kt b/src/main/kotlin/com/github/pyvenvmanage/VenvVersionCache.kt new file mode 100644 index 0000000..333aa25 --- /dev/null +++ b/src/main/kotlin/com/github/pyvenvmanage/VenvVersionCache.kt @@ -0,0 +1,65 @@ +package com.github.pyvenvmanage + +import java.util.concurrent.ConcurrentHashMap + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.vfs.AsyncFileListener +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent +import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent + +@Service(Service.Level.APP) +class VenvVersionCache : Disposable { + private val cache = ConcurrentHashMap() + + init { + VirtualFileManager.getInstance().addAsyncFileListener( + { events -> + val pyvenvChanges = + events.filter { event -> + val path = event.path + path.endsWith("pyvenv.cfg") && + (event is VFileContentChangeEvent || event is VFileDeleteEvent) + } + if (pyvenvChanges.isNotEmpty()) { + object : AsyncFileListener.ChangeApplier { + override fun afterVfsChange() { + pyvenvChanges.forEach { event -> + invalidate(event.path) + } + } + } + } else { + null + } + }, + this, + ) + } + + fun getVersion(pyvenvCfgPath: String): String? = + cache.computeIfAbsent(pyvenvCfgPath) { + VenvUtils.getPythonVersionFromPyVenv( + java.nio.file.Path + .of(it), + ) + } + + fun invalidate(path: String) { + cache.remove(path) + } + + fun clear() { + cache.clear() + } + + override fun dispose() { + cache.clear() + } + + companion object { + fun getInstance(): VenvVersionCache = service() + } +} diff --git a/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt b/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt index 6420073..22b194f 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt @@ -37,14 +37,20 @@ class VenvProjectViewNodeDecoratorTest { @Nested inner class DecorateTest { + private lateinit var versionCache: VenvVersionCache + @BeforeEach fun setUpMocks() { mockkObject(VenvUtils) + versionCache = mockk(relaxed = true) + mockkObject(VenvVersionCache.Companion) + every { VenvVersionCache.getInstance() } returns versionCache } @AfterEach fun tearDown() { unmockkObject(VenvUtils) + unmockkObject(VenvVersionCache.Companion) } @Test @@ -75,6 +81,7 @@ class VenvProjectViewNodeDecoratorTest { Files.writeString(pyvenvCfgPath, "version = 3.11.0") every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath + every { versionCache.getVersion(pyvenvCfgPath.toString()) } returns "3.11.0" every { data.presentableText } returns "venv" decorator.decorate(node, data) @@ -90,6 +97,7 @@ class VenvProjectViewNodeDecoratorTest { Files.writeString(pyvenvCfgPath, "version = 3.11.0") every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath + every { versionCache.getVersion(pyvenvCfgPath.toString()) } returns "3.11.0" every { data.presentableText } returns "venv" decorator.decorate(node, data) @@ -107,6 +115,7 @@ class VenvProjectViewNodeDecoratorTest { Files.writeString(pyvenvCfgPath, "home = /usr/bin") every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath + every { versionCache.getVersion(pyvenvCfgPath.toString()) } returns null every { data.presentableText } returns "venv" decorator.decorate(node, data) @@ -117,21 +126,22 @@ class VenvProjectViewNodeDecoratorTest { } @Test - fun `caches version between calls`( + fun `uses cache for version lookup`( @TempDir tempDir: Path, ) { val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") Files.writeString(pyvenvCfgPath, "version = 3.11.0") every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath + every { versionCache.getVersion(pyvenvCfgPath.toString()) } returns "3.11.0" 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) } + // Version cache should be called twice (caching is handled by the cache service) + verify(exactly = 2) { versionCache.getVersion(pyvenvCfgPath.toString()) } } } } diff --git a/src/test/kotlin/com/github/pyvenvmanage/VenvUtilsVersionTest.kt b/src/test/kotlin/com/github/pyvenvmanage/VenvUtilsVersionTest.kt new file mode 100644 index 0000000..e407dd2 --- /dev/null +++ b/src/test/kotlin/com/github/pyvenvmanage/VenvUtilsVersionTest.kt @@ -0,0 +1,74 @@ +package com.github.pyvenvmanage + +import java.nio.file.Files + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +/** + * Tests for VenvUtils.getPythonVersionFromPyVenv + * VenvVersionCache is tested via UI tests since it requires IntelliJ Application context + */ +class VenvUtilsVersionTest { + @TempDir + lateinit var tempDir: java.nio.file.Path + + @Test + fun `getPythonVersionFromPyVenv returns version from pyvenv cfg`() { + val pyvenvCfg = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfg, "version = 3.11.5\nhome = /usr/bin") + + val version = VenvUtils.getPythonVersionFromPyVenv(pyvenvCfg) + + assertEquals("3.11.5", version) + } + + @Test + fun `getPythonVersionFromPyVenv trims whitespace`() { + val pyvenvCfg = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfg, "version = 3.11.5 \nhome = /usr/bin") + + val version = VenvUtils.getPythonVersionFromPyVenv(pyvenvCfg) + + assertEquals("3.11.5", version) + } + + @Test + fun `getPythonVersionFromPyVenv returns null for missing file`() { + val pyvenvCfg = tempDir.resolve("nonexistent").resolve("pyvenv.cfg") + + val version = VenvUtils.getPythonVersionFromPyVenv(pyvenvCfg) + + assertNull(version) + } + + @Test + fun `getPythonVersionFromPyVenv returns null for file without version`() { + val pyvenvCfg = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfg, "home = /usr/bin\ninclude-system-site-packages = false") + + val version = VenvUtils.getPythonVersionFromPyVenv(pyvenvCfg) + + assertNull(version) + } + + @Test + fun `getPythonVersionFromPyVenv handles complex pyvenv cfg`() { + val pyvenvCfg = tempDir.resolve("pyvenv.cfg") + Files.writeString( + pyvenvCfg, + """ + home = /usr/local/bin + include-system-site-packages = false + version = 3.12.0 + executable = /usr/local/bin/python3.12 + """.trimIndent(), + ) + + val version = VenvUtils.getPythonVersionFromPyVenv(pyvenvCfg) + + assertEquals("3.12.0", version) + } +}