mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
PY-90394 don't persist Python tool settings left at their defaults
Drop a ToolEntry from PyToolsState once it equals the default, so pyLspTools.xml only stores non-default tools and is removed on reset. Fields become read-only @OptionTag val (still serialized); callers move to setEnabled/setMode. (cherry picked from commit 35efbd229d5f7037bd2a059db907ffcfde85a32c) IJ-MR-209196 GitOrigin-RevId: 015c7a0683c7e705cbf1665b7fc8345ce22efe0b
This commit is contained in:
committed by
intellij-monorepo-bot
parent
d87367f4c1
commit
29012a7608
@@ -7,13 +7,13 @@ import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Version
|
||||
import com.intellij.openapi.util.registry.Registry
|
||||
import com.intellij.python.pytools.PyTool
|
||||
import com.intellij.python.pytools.PyToolsState
|
||||
import com.intellij.python.pytools.configuration.ExecutableDiscoveryMode
|
||||
import com.intellij.python.black.PyBlackBundle.message
|
||||
import com.intellij.python.black.configuration.BlackFormatterConfigurable
|
||||
import com.intellij.python.black.configuration.BlackFormatterConfiguration
|
||||
import com.jetbrains.python.packaging.PyPackageName
|
||||
import org.jetbrains.annotations.ApiStatus
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.Path
|
||||
|
||||
@ApiStatus.Internal
|
||||
@@ -28,22 +28,23 @@ class BlackPyTool : PyTool {
|
||||
*/
|
||||
override val minimumSupportedVersion: Version = Version(23, 11, 0)
|
||||
|
||||
override fun legacyEnabled(project: Project): Boolean {
|
||||
return Registry.`is`("black.formatter.support.enabled")
|
||||
&& BlackFormatterConfiguration.getBlackConfiguration(project).enabledOnReformat
|
||||
@Suppress("DEPRECATION")
|
||||
override fun migrateLegacyState(project: Project): PyToolsState.ToolEntry {
|
||||
val cfg = BlackFormatterConfiguration.getBlackConfiguration(project)
|
||||
val entry = PyToolsState.ToolEntry(
|
||||
enabled = Registry.`is`("black.formatter.support.enabled") && cfg.enabledOnReformat,
|
||||
discoveryMode = when (cfg.executionMode) {
|
||||
BlackFormatterConfiguration.ExecutionMode.BINARY -> ExecutableDiscoveryMode.PATH
|
||||
BlackFormatterConfiguration.ExecutionMode.PACKAGE -> ExecutableDiscoveryMode.INTERPRETER
|
||||
},
|
||||
customToolBinaryPath = cfg.pathToExecutable?.takeIf { it.isNotBlank() }?.let { Path(it) },
|
||||
)
|
||||
cfg.enabledOnReformat = false
|
||||
cfg.executionMode = BlackFormatterConfiguration.ExecutionMode.PACKAGE
|
||||
cfg.pathToExecutable = null
|
||||
return entry
|
||||
}
|
||||
|
||||
override fun legacyDiscoveryMode(project: Project): ExecutableDiscoveryMode =
|
||||
when (BlackFormatterConfiguration.getBlackConfiguration(project).executionMode) {
|
||||
BlackFormatterConfiguration.ExecutionMode.BINARY -> ExecutableDiscoveryMode.PATH
|
||||
BlackFormatterConfiguration.ExecutionMode.PACKAGE -> ExecutableDiscoveryMode.INTERPRETER
|
||||
}
|
||||
|
||||
override fun legacyCustomPath(project: Project): Path? =
|
||||
BlackFormatterConfiguration.getBlackConfiguration(project).pathToExecutable
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { Path(it) }
|
||||
|
||||
override val detailConfigurable: (Project) -> UnnamedConfigurable = ::BlackFormatterConfigurable
|
||||
|
||||
override fun summaryFor(project: Project): String {
|
||||
|
||||
+4
-7
@@ -14,9 +14,11 @@ import com.intellij.openapi.application.EDT
|
||||
import com.intellij.openapi.application.runReadAction
|
||||
import com.intellij.openapi.application.writeIntentReadAction
|
||||
import com.intellij.openapi.command.WriteCommandAction
|
||||
import com.intellij.python.black.BlackPyTool
|
||||
import com.intellij.python.junit5Tests.framework.env.PyEnvTestCase
|
||||
import com.intellij.python.junit5Tests.framework.env.pySdkFixture
|
||||
import com.intellij.python.pytools.getState
|
||||
import com.intellij.python.pytools.PyToolsState
|
||||
import com.intellij.python.pytools.configuration.ExecutableDiscoveryMode
|
||||
import com.intellij.testFramework.common.timeoutRunBlocking
|
||||
import com.intellij.testFramework.junit5.fixture.editorFixture
|
||||
import com.intellij.testFramework.junit5.fixture.moduleFixture
|
||||
@@ -24,8 +26,6 @@ import com.intellij.testFramework.junit5.fixture.projectFixture
|
||||
import com.intellij.testFramework.junit5.fixture.psiFileFixture
|
||||
import com.intellij.testFramework.junit5.fixture.sourceRootFixture
|
||||
import com.intellij.testFramework.junit5.fixture.tempPathFixture
|
||||
import com.intellij.python.black.BlackPyTool
|
||||
import com.intellij.python.pytools.configuration.ExecutableDiscoveryMode
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
@@ -52,10 +52,7 @@ internal class BlackActionOnSaveTest {
|
||||
@JvmStatic
|
||||
@BeforeAll
|
||||
fun enableBlack() {
|
||||
with (BlackPyTool.getInstance().getState(projectFixture.get())) {
|
||||
enabled = true
|
||||
discoveryMode = ExecutableDiscoveryMode.INTERPRETER
|
||||
}
|
||||
PyToolsState.getInstance(projectFixture.get()).setEnabled(BlackPyTool.getInstance(), true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
// Copyright 2000-2026 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.tests.black
|
||||
|
||||
import com.intellij.python.black.BlackPyTool
|
||||
import com.intellij.python.black.configuration.BlackFormatterConfiguration
|
||||
import com.intellij.python.junit5Tests.framework.env.PyEnvTestCase
|
||||
import com.intellij.python.pytools.PyToolsState
|
||||
import com.intellij.python.pytools.configuration.ExecutableDiscoveryMode
|
||||
import com.intellij.testFramework.junit5.fixture.projectFixture
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.io.path.Path
|
||||
|
||||
/**
|
||||
* Verifies the one-way migration of Black's pre-[PyToolsState] settings. The old
|
||||
* [BlackFormatterConfiguration] fields must be imported into a [PyToolsState.ToolEntry] and then
|
||||
* cleared, so that re-running the migration (the reset-then-reopen path) imports nothing and cannot
|
||||
* resurrect the old values. The `black.formatter.support.enabled` registry key defaults to `true`.
|
||||
*/
|
||||
@PyEnvTestCase
|
||||
internal class BlackMigrationTest {
|
||||
private val projectFixture = projectFixture()
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Test
|
||||
fun `migrate imports legacy Black settings, then clears them so re-running imports nothing`() {
|
||||
val project = projectFixture.get()
|
||||
val cfg = BlackFormatterConfiguration.getBlackConfiguration(project).apply {
|
||||
enabledOnReformat = true
|
||||
executionMode = BlackFormatterConfiguration.ExecutionMode.BINARY
|
||||
pathToExecutable = "/usr/local/bin/black"
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
PyToolsState.ToolEntry(
|
||||
enabled = true,
|
||||
discoveryMode = ExecutableDiscoveryMode.PATH,
|
||||
customToolBinaryPath = Path("/usr/local/bin/black"),
|
||||
),
|
||||
BlackPyTool.getInstance().migrateLegacyState(project),
|
||||
)
|
||||
|
||||
// old settings are wiped, so a second migration (e.g. after the file was emptied on reset) imports nothing
|
||||
assertEquals(false, cfg.enabledOnReformat)
|
||||
assertEquals(BlackFormatterConfiguration.ExecutionMode.PACKAGE, cfg.executionMode)
|
||||
assertNull(cfg.pathToExecutable)
|
||||
assertEquals(PyToolsState.ToolEntry(), BlackPyTool.getInstance().migrateLegacyState(project))
|
||||
}
|
||||
}
|
||||
+9
-13
@@ -2,19 +2,18 @@
|
||||
package com.intellij.python.junit5Tests.env.tests.black
|
||||
|
||||
import com.intellij.openapi.application.runReadAction
|
||||
import com.intellij.python.junit5Tests.framework.env.PyEnvTestCase
|
||||
import com.intellij.python.junit5Tests.framework.env.pySdkFixture
|
||||
import com.intellij.python.pyproject.PY_PROJECT_TOML
|
||||
import com.intellij.python.pytools.getState
|
||||
import com.intellij.testFramework.common.timeoutRunBlocking
|
||||
import com.intellij.testFramework.junit5.fixture.moduleFixture
|
||||
import com.intellij.testFramework.junit5.fixture.projectFixture
|
||||
import com.intellij.testFramework.junit5.fixture.tempPathFixture
|
||||
import com.intellij.python.black.BlackFormattingRequest
|
||||
import com.intellij.python.black.BlackFormattingResponse
|
||||
import com.intellij.python.black.BlackPyTool
|
||||
import com.intellij.python.black.execute
|
||||
import com.intellij.python.pytools.configuration.ExecutableDiscoveryMode
|
||||
import com.intellij.python.junit5Tests.framework.env.PyEnvTestCase
|
||||
import com.intellij.python.junit5Tests.framework.env.pySdkFixture
|
||||
import com.intellij.python.pyproject.PY_PROJECT_TOML
|
||||
import com.intellij.python.pytools.PyToolsState
|
||||
import com.intellij.testFramework.common.timeoutRunBlocking
|
||||
import com.intellij.testFramework.junit5.fixture.moduleFixture
|
||||
import com.intellij.testFramework.junit5.fixture.projectFixture
|
||||
import com.intellij.testFramework.junit5.fixture.tempPathFixture
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
@@ -38,10 +37,7 @@ internal class BlackPyProjectTomlAppliedTest {
|
||||
@JvmStatic
|
||||
@BeforeAll
|
||||
fun enableBlack() {
|
||||
with (BlackPyTool.getInstance().getState(projectFixture.get())) {
|
||||
enabled = true
|
||||
discoveryMode = ExecutableDiscoveryMode.INTERPRETER
|
||||
}
|
||||
PyToolsState.getInstance(projectFixture.get()).setEnabled(BlackPyTool.getInstance(), true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-7
@@ -12,16 +12,16 @@ import com.intellij.psi.PsiDocumentManager
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.PsiManager
|
||||
import com.intellij.psi.codeStyle.CodeStyleManager
|
||||
import com.intellij.python.black.BlackPyTool
|
||||
import com.intellij.python.black.configuration.BlackFormatterConfiguration
|
||||
import com.intellij.python.junit5Tests.framework.env.PyEnvTestCase
|
||||
import com.intellij.python.junit5Tests.framework.env.pySdkFixture
|
||||
import com.intellij.python.pytools.PyToolsState
|
||||
import com.intellij.python.pytools.configuration.ExecutableDiscoveryMode
|
||||
import com.intellij.python.pytools.getState
|
||||
import com.intellij.testFramework.common.timeoutRunBlocking
|
||||
import com.intellij.testFramework.junit5.fixture.moduleFixture
|
||||
import com.intellij.testFramework.junit5.fixture.projectFixture
|
||||
import com.intellij.testFramework.junit5.fixture.tempPathFixture
|
||||
import com.intellij.python.black.BlackPyTool
|
||||
import com.intellij.python.black.configuration.BlackFormatterConfiguration
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
@@ -42,10 +42,7 @@ internal class BlackReformatFileTest {
|
||||
@JvmStatic
|
||||
@BeforeAll
|
||||
fun enableBlack() {
|
||||
with (BlackPyTool.getInstance().getState(projectFixture.get())) {
|
||||
enabled = true
|
||||
discoveryMode = ExecutableDiscoveryMode.INTERPRETER
|
||||
}
|
||||
PyToolsState.getInstance(projectFixture.get()).setEnabled(BlackPyTool.getInstance(), true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-7
@@ -2,14 +2,14 @@
|
||||
package com.intellij.python.junit5Tests.env.tests.black
|
||||
|
||||
import com.intellij.openapi.application.runReadAction
|
||||
import com.intellij.python.black.BlackPyTool
|
||||
import com.intellij.python.junit5Tests.framework.env.PyEnvTestCase
|
||||
import com.intellij.python.junit5Tests.framework.env.pySdkFixture
|
||||
import com.intellij.python.pytools.getState
|
||||
import com.intellij.python.pytools.PyToolsState
|
||||
import com.intellij.python.pytools.configuration.ExecutableDiscoveryMode
|
||||
import com.intellij.testFramework.junit5.fixture.moduleFixture
|
||||
import com.intellij.testFramework.junit5.fixture.projectFixture
|
||||
import com.intellij.testFramework.junit5.fixture.tempPathFixture
|
||||
import com.intellij.python.black.BlackPyTool
|
||||
import com.intellij.python.pytools.configuration.ExecutableDiscoveryMode
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -30,10 +30,7 @@ internal class BlackReformatFragmentTest {
|
||||
@JvmStatic
|
||||
@BeforeAll
|
||||
fun enableBlack() {
|
||||
with (BlackPyTool.getInstance().getState(projectFixture.get())) {
|
||||
enabled = true
|
||||
discoveryMode = ExecutableDiscoveryMode.INTERPRETER
|
||||
}
|
||||
PyToolsState.getInstance(projectFixture.get()).setEnabled(BlackPyTool.getInstance(), true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import com.intellij.openapi.extensions.ExtensionPointName
|
||||
import com.intellij.openapi.options.UnnamedConfigurable
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.NlsSafe
|
||||
import com.intellij.python.pytools.configuration.ExecutableDiscoveryMode
|
||||
import com.intellij.python.pytools.statistics.PyToolFusSnapshot
|
||||
import com.jetbrains.python.packaging.PyPackageName
|
||||
import org.jetbrains.annotations.Nls
|
||||
@@ -47,14 +46,13 @@ interface PyTool {
|
||||
*/
|
||||
val minimumSupportedVersion: PlatformVersion? get() = null
|
||||
|
||||
/** Pre-migration `enabled` value, used by [PyToolsState] on first read. */
|
||||
fun legacyEnabled(project: Project): Boolean = false
|
||||
|
||||
/** Pre-migration discovery mode, used by [PyToolsState] on first read. */
|
||||
fun legacyDiscoveryMode(project: Project): ExecutableDiscoveryMode = ExecutableDiscoveryMode.INTERPRETER
|
||||
|
||||
/** Pre-migration custom path, used by [PyToolsState] on first read. */
|
||||
fun legacyCustomPath(project: Project): java.nio.file.Path? = null
|
||||
/**
|
||||
* One-time migration from this tool's pre-[PyToolsState] configuration. Called once per project by
|
||||
* [PyToolsState] when it has no stored state yet. Implementations read their old settings, **reset those
|
||||
* old settings to their defaults** (so the migration is one-way and re-running it can never resurrect the
|
||||
* old values), and return the equivalent [PyToolsState.ToolEntry] — or `null` if there is nothing to migrate.
|
||||
*/
|
||||
fun migrateLegacyState(project: Project): PyToolsState.ToolEntry? = null
|
||||
|
||||
/**
|
||||
* Factory that builds the per-tool detail UI shown in the Edit dialog of the External Tools
|
||||
|
||||
@@ -18,18 +18,21 @@ import kotlin.io.path.Path
|
||||
/**
|
||||
* Central per-project state for all Python tools that participate in the [PyTool] extension point.
|
||||
*
|
||||
* One [ToolEntry] per tool, keyed by [PyTool.fusId]. First-time reads fall back to each tool's
|
||||
* legacy `enabled` / mode / path values (see [PyTool.legacyEnabled], [PyTool.legacyDiscoveryMode], [PyTool.legacyCustomPath])
|
||||
* so users upgrading from the per-tool configuration model do not lose their settings.
|
||||
* One [ToolEntry] per tool, keyed by [PyTool.fusId]; a tool absent from [State.tools] is at its defaults.
|
||||
* The first time a project has no stored state (see [noStateLoaded]) each tool's pre-existing configuration is
|
||||
* imported once via [PyTool.migrateLegacyState], which also clears the old settings so the migration is one-way and can
|
||||
* never resurrect stale values after a reset to defaults.
|
||||
*/
|
||||
@Service(Service.Level.PROJECT)
|
||||
@State(name = "PyToolsState", storages = [Storage(LSP_TOOLS_STORAGE_FILE)])
|
||||
class PyToolsState(private val project: Project) : PersistentStateComponent<PyToolsState.State> {
|
||||
data class ToolEntry(
|
||||
var enabled: Boolean = false,
|
||||
var discoveryMode: ExecutableDiscoveryMode = ExecutableDiscoveryMode.INTERPRETER,
|
||||
@OptionTag
|
||||
val enabled: Boolean = false,
|
||||
@OptionTag
|
||||
val discoveryMode: ExecutableDiscoveryMode = ExecutableDiscoveryMode.INTERPRETER,
|
||||
@OptionTag(value = "customPathToExecutable", converter = PathConverter::class)
|
||||
var customToolBinaryPath: Path? = null,
|
||||
val customToolBinaryPath: Path? = null,
|
||||
)
|
||||
|
||||
internal class PathConverter : Converter<Path>() {
|
||||
@@ -37,7 +40,19 @@ class PyToolsState(private val project: Project) : PersistentStateComponent<PyTo
|
||||
override fun toString(value: Path): String = value.toString()
|
||||
}
|
||||
|
||||
data class State(var tools: MutableMap<String, ToolEntry> = ConcurrentHashMap())
|
||||
data class State(
|
||||
@OptionTag
|
||||
val tools: MutableMap<String, ToolEntry> = ConcurrentHashMap(),
|
||||
) {
|
||||
internal fun persist(tool: PyTool, entry: ToolEntry) {
|
||||
if (entry == DEFAULT_TOOL_ENTRY) {
|
||||
tools.remove(tool.fusId)
|
||||
}
|
||||
else {
|
||||
tools[tool.fusId] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var state = State()
|
||||
|
||||
@@ -46,34 +61,38 @@ class PyToolsState(private val project: Project) : PersistentStateComponent<PyTo
|
||||
this.state = state
|
||||
}
|
||||
|
||||
fun getEntry(tool: PyTool): ToolEntry {
|
||||
val key = tool.fusId
|
||||
state.tools[key]?.let { return it }
|
||||
val migrated = ToolEntry(
|
||||
enabled = tool.legacyEnabled(project),
|
||||
discoveryMode = tool.legacyDiscoveryMode(project),
|
||||
customToolBinaryPath = tool.legacyCustomPath(project),
|
||||
)
|
||||
return state.tools.putIfAbsent(key, migrated) ?: migrated
|
||||
/**
|
||||
* Runs once per project, the first time there is no stored [PyToolsState] yet: imports each tool's pre-existing
|
||||
* configuration via [PyTool.migrateLegacyState] (which also clears the old settings). Because the old settings are cleared,
|
||||
* re-running this after a reset to defaults imports nothing and cannot resurrect stale values.
|
||||
*/
|
||||
override fun noStateLoaded() {
|
||||
for (tool in PyTool.EP_NAME.extensionList) {
|
||||
val entry = tool.migrateLegacyState(project) ?: continue
|
||||
state.persist(tool, entry)
|
||||
}
|
||||
}
|
||||
|
||||
fun getEntry(tool: PyTool): ToolEntry = state.tools[tool.fusId] ?: DEFAULT_TOOL_ENTRY
|
||||
|
||||
fun isEnabled(tool: PyTool): Boolean = getEntry(tool).enabled
|
||||
fun setEnabled(tool: PyTool, value: Boolean) {
|
||||
getEntry(tool).enabled = value
|
||||
state.persist(tool, getEntry(tool).copy(enabled = value))
|
||||
}
|
||||
|
||||
fun getMode(tool: PyTool): ExecutableDiscoveryMode = getEntry(tool).discoveryMode
|
||||
fun setMode(tool: PyTool, value: ExecutableDiscoveryMode) {
|
||||
getEntry(tool).discoveryMode = value
|
||||
state.persist(tool, getEntry(tool).copy(discoveryMode = value))
|
||||
}
|
||||
|
||||
fun getCustomPath(tool: PyTool): Path? = getEntry(tool).customToolBinaryPath
|
||||
|
||||
fun setCustomPath(tool: PyTool, value: Path?) {
|
||||
getEntry(tool).customToolBinaryPath = value
|
||||
state.persist(tool, getEntry(tool).copy(customToolBinaryPath = value))
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getInstance(project: Project): PyToolsState = project.service()
|
||||
private val DEFAULT_TOOL_ENTRY = ToolEntry()
|
||||
}
|
||||
}
|
||||
|
||||
+19
-2
@@ -2,6 +2,7 @@
|
||||
package com.intellij.python.pytools.lsp
|
||||
|
||||
import com.intellij.openapi.components.PersistentStateComponent
|
||||
import com.intellij.python.pytools.PyToolsState
|
||||
import com.intellij.python.pytools.configuration.ExecutableDiscoveryMode
|
||||
import com.intellij.util.xmlb.XmlSerializerUtil
|
||||
import java.nio.file.Path
|
||||
@@ -63,11 +64,27 @@ abstract class PyLspToolConfiguration<State : PyLspToolConfiguration<State>> : P
|
||||
@Deprecated("replaced with PyToolState")
|
||||
override var sdkName: String = DEFAULT_ENVIRONMENT
|
||||
|
||||
fun isAnyFeatureEnabled(): Boolean = inspections || completions == true || inlayHints == true || documentation == true
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
final override fun getState(): State = this as State
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun loadState(state: State): Unit = XmlSerializerUtil.copyBean(state, this as State)
|
||||
|
||||
/**
|
||||
* Reads the pre-[PyToolsState] enable / discovery-mode / custom-path settings into a [PyToolsState.ToolEntry]
|
||||
* and clears them from this configuration, so [PyToolsState]'s one-time migration is one-way: re-running it
|
||||
* can never resurrect the old values. Feature flags (inspections, completions, ...) are left untouched.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
fun migrateToPyToolState(): PyToolsState.ToolEntry {
|
||||
val entry = PyToolsState.ToolEntry(
|
||||
enabled = enabled,
|
||||
discoveryMode = executableDiscoveryMode,
|
||||
customToolBinaryPath = executablePath,
|
||||
)
|
||||
enabled = false
|
||||
executableDiscoveryMode = ExecutableDiscoveryMode.INTERPRETER
|
||||
pathToExecutable = ""
|
||||
return entry
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,8 @@ jvm_library(
|
||||
"//platform/util:util_test_lib",
|
||||
"//platform/util/jdom",
|
||||
"//platform/util/jdom:jdom_test_lib",
|
||||
"//python/openapi:community",
|
||||
"//python/openapi:community_test_lib",
|
||||
],
|
||||
)
|
||||
### auto-generated section `build intellij.python.pytools.tests` end
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<orderEntry type="module" module-name="intellij.platform.testFramework.junit5" scope="TEST" />
|
||||
<orderEntry type="module" module-name="intellij.platform.util" scope="TEST" />
|
||||
<orderEntry type="module" module-name="intellij.platform.util.jdom" scope="TEST" />
|
||||
<orderEntry type="module" module-name="intellij.python.community" scope="TEST" />
|
||||
<orderEntry type="module" module-name="intellij.python.pytools" scope="TEST" />
|
||||
</component>
|
||||
<component name="TestModuleProperties" production-module="intellij.python.pytools" />
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<module name="intellij.platform.testFramework"/>
|
||||
<module name="intellij.platform.testFramework.common"/>
|
||||
<module name="intellij.platform.testFramework.junit5"/>
|
||||
<module name="intellij.python.community"/>
|
||||
<module name="intellij.python.pytools"/>
|
||||
</dependencies>
|
||||
<!-- endregion -->
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.intellij.python.junit5Tests.unit.pytools
|
||||
|
||||
import com.intellij.python.pytools.PyToolsState
|
||||
import com.intellij.python.pytools.configuration.ExecutableDiscoveryMode
|
||||
import com.intellij.python.pytools.lsp.PyLspToolConfiguration
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.io.path.Path
|
||||
|
||||
/**
|
||||
* Regression for the migration resurrection bug: legacy settings used to be re-read on every
|
||||
* [PyToolsState.getEntry], so resetting a migrated tool back to its defaults (which drops its
|
||||
* stored entry) would resurrect the old values on the next read.
|
||||
*
|
||||
* The fix makes migration one-way — [PyLspToolConfiguration.migrateToPyToolState] clears the old
|
||||
* settings as it imports them — so re-running the migration (e.g. after the storage file was
|
||||
* emptied on reset) imports nothing.
|
||||
*/
|
||||
internal class PyLspToolConfigurationMigrationTest {
|
||||
private class FakeConfig : PyLspToolConfiguration<FakeConfig>()
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Test
|
||||
fun `migrateToToolEntry imports legacy settings, then clears them so re-running imports nothing`() {
|
||||
val cfg = FakeConfig().apply {
|
||||
enabled = true
|
||||
executableDiscoveryMode = ExecutableDiscoveryMode.PATH
|
||||
pathToExecutable = "/usr/local/bin/ruff"
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
PyToolsState.ToolEntry(
|
||||
enabled = true,
|
||||
discoveryMode = ExecutableDiscoveryMode.PATH,
|
||||
customToolBinaryPath = Path("/usr/local/bin/ruff"),
|
||||
),
|
||||
cfg.migrateToPyToolState(),
|
||||
)
|
||||
|
||||
// old settings are wiped, so a second migration (the reset-then-reopen path) yields nothing to import
|
||||
assertEquals(false, cfg.enabled)
|
||||
assertEquals(ExecutableDiscoveryMode.INTERPRETER, cfg.executableDiscoveryMode)
|
||||
assertEquals("", cfg.pathToExecutable)
|
||||
assertEquals(PyToolsState.ToolEntry(), cfg.migrateToPyToolState())
|
||||
}
|
||||
}
|
||||
+37
@@ -3,8 +3,13 @@ package com.intellij.python.junit5Tests.unit.pytools
|
||||
|
||||
import com.intellij.openapi.util.JDOMUtil
|
||||
import com.intellij.python.pytools.PyToolsState
|
||||
import com.intellij.python.pytools.PyTool
|
||||
import com.intellij.python.pytools.configuration.ExecutableDiscoveryMode
|
||||
import com.intellij.configurationStore.serialize
|
||||
import com.jetbrains.python.packaging.PyPackageName
|
||||
import com.intellij.util.xmlb.XmlSerializer
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -57,4 +62,36 @@ internal class PyToolsStateSerializationTest {
|
||||
val element = XmlSerializer.serialize(PyToolsState.State())
|
||||
XmlSerializer.deserialize(element, PyToolsState.State::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `.idea/pyLspTools.xml` file is written by the component store only while [PyToolsState.State]
|
||||
* serializes to non-empty content. [com.intellij.configurationStore.serialize] (the same entry point the
|
||||
* store uses, with the default skip-defaults filter) returns `null` for an all-default bean, which is exactly
|
||||
* the "no file on disk" condition. Driving the change through [PyToolsState.State.persist] also covers the
|
||||
* remove-on-default path that makes the file disappear again once every tool is back to its defaults.
|
||||
*/
|
||||
@Test
|
||||
fun `state has no storage content at defaults, gains it on change, and loses it again on revert`() {
|
||||
val state = PyToolsState.State()
|
||||
val tool = object : PyTool {
|
||||
override val presentableName: String = "ruff"
|
||||
override val packageName: PyPackageName = PyPackageName.from("ruff")
|
||||
override val description: String = "ruff"
|
||||
}
|
||||
|
||||
// All tools at their defaults -> nothing to persist -> no .idea/pyLspTools.xml.
|
||||
assertNull(serialize(state), "a default state must serialize to nothing (no storage file)")
|
||||
|
||||
// Change a setting -> the entry is persisted -> the file is written and holds the settings.
|
||||
state.persist(tool, PyToolsState.ToolEntry(enabled = true, discoveryMode = ExecutableDiscoveryMode.PATH))
|
||||
val element = serialize(state)
|
||||
assertNotNull(element, "a tool that differs from defaults must produce storage content")
|
||||
val xml = JDOMUtil.writeElement(element!!)
|
||||
assertTrue(xml.contains("ruff"), "storage must reference the changed tool; got: $xml")
|
||||
assertTrue(xml.contains("enabled"), "storage must contain the changed setting; got: $xml")
|
||||
|
||||
// Revert every setting to its default -> the entry is dropped -> the file is deleted.
|
||||
state.persist(tool, PyToolsState.ToolEntry())
|
||||
assertNull(serialize(state), "reverting all tools to defaults must serialize to nothing (storage file deleted)")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user