Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,17 +8,12 @@ import com.intellij.ui.SimpleTextAttributes
import com.jetbrains.python.icons.PythonIcons.Python.Virtualenv

class VenvProjectViewNodeDecorator : ProjectViewNodeDecorator {
private val versionCache = ConcurrentHashMap<String, String?>()

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()
Expand Down
65 changes: 65 additions & 0 deletions src/main/kotlin/com/github/pyvenvmanage/VenvVersionCache.kt
Original file line number Diff line number Diff line change
@@ -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<String, String?>()

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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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()) }
}
}
}
74 changes: 74 additions & 0 deletions src/test/kotlin/com/github/pyvenvmanage/VenvUtilsVersionTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}