Skip to content

Commit 53ff8c9

Browse files
authored
Add cache invalidation with file watcher (#101)
1 parent f3c0f08 commit 53ff8c9

File tree

4 files changed

+153
-11
lines changed

4 files changed

+153
-11
lines changed

src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
package com.github.pyvenvmanage
22

3-
import java.util.concurrent.ConcurrentHashMap
4-
53
import com.intellij.ide.projectView.PresentationData
64
import com.intellij.ide.projectView.ProjectViewNode
75
import com.intellij.ide.projectView.ProjectViewNodeDecorator
@@ -10,17 +8,12 @@ import com.intellij.ui.SimpleTextAttributes
108
import com.jetbrains.python.icons.PythonIcons.Python.Virtualenv
119

1210
class VenvProjectViewNodeDecorator : ProjectViewNodeDecorator {
13-
private val versionCache = ConcurrentHashMap<String, String?>()
14-
1511
override fun decorate(
1612
node: ProjectViewNode<*>,
1713
data: PresentationData,
1814
) {
1915
VenvUtils.getPyVenvCfg(node.virtualFile)?.let { pyVenvCfgPath ->
20-
val pythonVersion =
21-
versionCache.computeIfAbsent(pyVenvCfgPath.toString()) {
22-
VenvUtils.getPythonVersionFromPyVenv(pyVenvCfgPath)
23-
}
16+
val pythonVersion = VenvVersionCache.getInstance().getVersion(pyVenvCfgPath.toString())
2417
pythonVersion?.let { version ->
2518
data.presentableText?.let { fileName ->
2619
data.clearText()
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.github.pyvenvmanage
2+
3+
import java.util.concurrent.ConcurrentHashMap
4+
5+
import com.intellij.openapi.Disposable
6+
import com.intellij.openapi.components.Service
7+
import com.intellij.openapi.components.service
8+
import com.intellij.openapi.vfs.AsyncFileListener
9+
import com.intellij.openapi.vfs.VirtualFileManager
10+
import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent
11+
import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent
12+
13+
@Service(Service.Level.APP)
14+
class VenvVersionCache : Disposable {
15+
private val cache = ConcurrentHashMap<String, String?>()
16+
17+
init {
18+
VirtualFileManager.getInstance().addAsyncFileListener(
19+
{ events ->
20+
val pyvenvChanges =
21+
events.filter { event ->
22+
val path = event.path
23+
path.endsWith("pyvenv.cfg") &&
24+
(event is VFileContentChangeEvent || event is VFileDeleteEvent)
25+
}
26+
if (pyvenvChanges.isNotEmpty()) {
27+
object : AsyncFileListener.ChangeApplier {
28+
override fun afterVfsChange() {
29+
pyvenvChanges.forEach { event ->
30+
invalidate(event.path)
31+
}
32+
}
33+
}
34+
} else {
35+
null
36+
}
37+
},
38+
this,
39+
)
40+
}
41+
42+
fun getVersion(pyvenvCfgPath: String): String? =
43+
cache.computeIfAbsent(pyvenvCfgPath) {
44+
VenvUtils.getPythonVersionFromPyVenv(
45+
java.nio.file.Path
46+
.of(it),
47+
)
48+
}
49+
50+
fun invalidate(path: String) {
51+
cache.remove(path)
52+
}
53+
54+
fun clear() {
55+
cache.clear()
56+
}
57+
58+
override fun dispose() {
59+
cache.clear()
60+
}
61+
62+
companion object {
63+
fun getInstance(): VenvVersionCache = service()
64+
}
65+
}

src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,20 @@ class VenvProjectViewNodeDecoratorTest {
3737

3838
@Nested
3939
inner class DecorateTest {
40+
private lateinit var versionCache: VenvVersionCache
41+
4042
@BeforeEach
4143
fun setUpMocks() {
4244
mockkObject(VenvUtils)
45+
versionCache = mockk(relaxed = true)
46+
mockkObject(VenvVersionCache.Companion)
47+
every { VenvVersionCache.getInstance() } returns versionCache
4348
}
4449

4550
@AfterEach
4651
fun tearDown() {
4752
unmockkObject(VenvUtils)
53+
unmockkObject(VenvVersionCache.Companion)
4854
}
4955

5056
@Test
@@ -75,6 +81,7 @@ class VenvProjectViewNodeDecoratorTest {
7581
Files.writeString(pyvenvCfgPath, "version = 3.11.0")
7682

7783
every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath
84+
every { versionCache.getVersion(pyvenvCfgPath.toString()) } returns "3.11.0"
7885
every { data.presentableText } returns "venv"
7986

8087
decorator.decorate(node, data)
@@ -90,6 +97,7 @@ class VenvProjectViewNodeDecoratorTest {
9097
Files.writeString(pyvenvCfgPath, "version = 3.11.0")
9198

9299
every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath
100+
every { versionCache.getVersion(pyvenvCfgPath.toString()) } returns "3.11.0"
93101
every { data.presentableText } returns "venv"
94102

95103
decorator.decorate(node, data)
@@ -107,6 +115,7 @@ class VenvProjectViewNodeDecoratorTest {
107115
Files.writeString(pyvenvCfgPath, "home = /usr/bin")
108116

109117
every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath
118+
every { versionCache.getVersion(pyvenvCfgPath.toString()) } returns null
110119
every { data.presentableText } returns "venv"
111120

112121
decorator.decorate(node, data)
@@ -117,21 +126,22 @@ class VenvProjectViewNodeDecoratorTest {
117126
}
118127

119128
@Test
120-
fun `caches version between calls`(
129+
fun `uses cache for version lookup`(
121130
@TempDir tempDir: Path,
122131
) {
123132
val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg")
124133
Files.writeString(pyvenvCfgPath, "version = 3.11.0")
125134

126135
every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath
136+
every { versionCache.getVersion(pyvenvCfgPath.toString()) } returns "3.11.0"
127137
every { data.presentableText } returns "venv"
128138

129139
// Call twice
130140
decorator.decorate(node, data)
131141
decorator.decorate(node, data)
132142

133-
// Version should only be read once due to caching
134-
verify(exactly = 1) { VenvUtils.getPythonVersionFromPyVenv(pyvenvCfgPath) }
143+
// Version cache should be called twice (caching is handled by the cache service)
144+
verify(exactly = 2) { versionCache.getVersion(pyvenvCfgPath.toString()) }
135145
}
136146
}
137147
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.github.pyvenvmanage
2+
3+
import java.nio.file.Files
4+
5+
import org.junit.jupiter.api.Assertions.assertEquals
6+
import org.junit.jupiter.api.Assertions.assertNull
7+
import org.junit.jupiter.api.Test
8+
import org.junit.jupiter.api.io.TempDir
9+
10+
/**
11+
* Tests for VenvUtils.getPythonVersionFromPyVenv
12+
* VenvVersionCache is tested via UI tests since it requires IntelliJ Application context
13+
*/
14+
class VenvUtilsVersionTest {
15+
@TempDir
16+
lateinit var tempDir: java.nio.file.Path
17+
18+
@Test
19+
fun `getPythonVersionFromPyVenv returns version from pyvenv cfg`() {
20+
val pyvenvCfg = tempDir.resolve("pyvenv.cfg")
21+
Files.writeString(pyvenvCfg, "version = 3.11.5\nhome = /usr/bin")
22+
23+
val version = VenvUtils.getPythonVersionFromPyVenv(pyvenvCfg)
24+
25+
assertEquals("3.11.5", version)
26+
}
27+
28+
@Test
29+
fun `getPythonVersionFromPyVenv trims whitespace`() {
30+
val pyvenvCfg = tempDir.resolve("pyvenv.cfg")
31+
Files.writeString(pyvenvCfg, "version = 3.11.5 \nhome = /usr/bin")
32+
33+
val version = VenvUtils.getPythonVersionFromPyVenv(pyvenvCfg)
34+
35+
assertEquals("3.11.5", version)
36+
}
37+
38+
@Test
39+
fun `getPythonVersionFromPyVenv returns null for missing file`() {
40+
val pyvenvCfg = tempDir.resolve("nonexistent").resolve("pyvenv.cfg")
41+
42+
val version = VenvUtils.getPythonVersionFromPyVenv(pyvenvCfg)
43+
44+
assertNull(version)
45+
}
46+
47+
@Test
48+
fun `getPythonVersionFromPyVenv returns null for file without version`() {
49+
val pyvenvCfg = tempDir.resolve("pyvenv.cfg")
50+
Files.writeString(pyvenvCfg, "home = /usr/bin\ninclude-system-site-packages = false")
51+
52+
val version = VenvUtils.getPythonVersionFromPyVenv(pyvenvCfg)
53+
54+
assertNull(version)
55+
}
56+
57+
@Test
58+
fun `getPythonVersionFromPyVenv handles complex pyvenv cfg`() {
59+
val pyvenvCfg = tempDir.resolve("pyvenv.cfg")
60+
Files.writeString(
61+
pyvenvCfg,
62+
"""
63+
home = /usr/local/bin
64+
include-system-site-packages = false
65+
version = 3.12.0
66+
executable = /usr/local/bin/python3.12
67+
""".trimIndent(),
68+
)
69+
70+
val version = VenvUtils.getPythonVersionFromPyVenv(pyvenvCfg)
71+
72+
assertEquals("3.12.0", version)
73+
}
74+
}

0 commit comments

Comments
 (0)