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:
Vitaly Legchilkin
2026-06-19 18:39:38 +00:00
committed by intellij-monorepo-bot
parent d87367f4c1
commit 29012a7608
14 changed files with 239 additions and 79 deletions
@@ -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 {
@@ -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)
}
}
@@ -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))
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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()
}
}
@@ -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
}
}
+2
View File
@@ -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 -->
@@ -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())
}
}
@@ -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)")
}
}