diff --git a/python/python-black/src/com/intellij/python/black/BlackPyTool.kt b/python/python-black/src/com/intellij/python/black/BlackPyTool.kt index 9527f39c05cd..0cf2ce1c1f4a 100644 --- a/python/python-black/src/com/intellij/python/black/BlackPyTool.kt +++ b/python/python-black/src/com/intellij/python/black/BlackPyTool.kt @@ -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 { diff --git a/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackActionOnSaveTest.kt b/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackActionOnSaveTest.kt index 468b86df1397..1be49c83c7cc 100644 --- a/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackActionOnSaveTest.kt +++ b/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackActionOnSaveTest.kt @@ -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) } } diff --git a/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackMigrationTest.kt b/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackMigrationTest.kt new file mode 100644 index 000000000000..dcc90beed5f4 --- /dev/null +++ b/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackMigrationTest.kt @@ -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)) + } +} diff --git a/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackPyProjectTomlAppliedTest.kt b/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackPyProjectTomlAppliedTest.kt index 3f0913b8a7fd..f34e29b1378f 100644 --- a/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackPyProjectTomlAppliedTest.kt +++ b/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackPyProjectTomlAppliedTest.kt @@ -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) } } diff --git a/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackReformatFileTest.kt b/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackReformatFileTest.kt index 8cfe2d582a53..1a6dcb5fc72f 100644 --- a/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackReformatFileTest.kt +++ b/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackReformatFileTest.kt @@ -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) } } diff --git a/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackReformatFragmentTest.kt b/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackReformatFragmentTest.kt index 0ea35e712d28..5a1b8a3df61a 100644 --- a/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackReformatFragmentTest.kt +++ b/python/python-black/tests/testSrc/com/intellij/python/junit5Tests/env/tests/black/BlackReformatFragmentTest.kt @@ -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) } } diff --git a/python/python-pytools/src/com/intellij/python/pytools/PyTool.kt b/python/python-pytools/src/com/intellij/python/pytools/PyTool.kt index 581967c18c92..d827dd9f690d 100644 --- a/python/python-pytools/src/com/intellij/python/pytools/PyTool.kt +++ b/python/python-pytools/src/com/intellij/python/pytools/PyTool.kt @@ -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 diff --git a/python/python-pytools/src/com/intellij/python/pytools/PyToolsState.kt b/python/python-pytools/src/com/intellij/python/pytools/PyToolsState.kt index 52f991635cea..ea2d3bc28759 100644 --- a/python/python-pytools/src/com/intellij/python/pytools/PyToolsState.kt +++ b/python/python-pytools/src/com/intellij/python/pytools/PyToolsState.kt @@ -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 { 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() { @@ -37,7 +40,19 @@ class PyToolsState(private val project: Project) : PersistentStateComponent = ConcurrentHashMap()) + data class State( + @OptionTag + val tools: MutableMap = 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> : 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 + } } diff --git a/python/python-pytools/tests/BUILD.bazel b/python/python-pytools/tests/BUILD.bazel index a274f01ece35..f078c3ea264d 100644 --- a/python/python-pytools/tests/BUILD.bazel +++ b/python/python-pytools/tests/BUILD.bazel @@ -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 diff --git a/python/python-pytools/tests/intellij.python.pytools.tests.iml b/python/python-pytools/tests/intellij.python.pytools.tests.iml index 5da5c824e6f6..b6709cf6a08d 100644 --- a/python/python-pytools/tests/intellij.python.pytools.tests.iml +++ b/python/python-pytools/tests/intellij.python.pytools.tests.iml @@ -29,6 +29,7 @@ + diff --git a/python/python-pytools/tests/testResources/intellij.python.pytools.tests.xml b/python/python-pytools/tests/testResources/intellij.python.pytools.tests.xml index a9026785d074..835d3820b9a7 100644 --- a/python/python-pytools/tests/testResources/intellij.python.pytools.tests.xml +++ b/python/python-pytools/tests/testResources/intellij.python.pytools.tests.xml @@ -17,6 +17,7 @@ + diff --git a/python/python-pytools/tests/testSrc/com/intellij/python/junit5Tests/unit/pytools/PyLspToolConfigurationMigrationTest.kt b/python/python-pytools/tests/testSrc/com/intellij/python/junit5Tests/unit/pytools/PyLspToolConfigurationMigrationTest.kt new file mode 100644 index 000000000000..3d2bf7bc1df7 --- /dev/null +++ b/python/python-pytools/tests/testSrc/com/intellij/python/junit5Tests/unit/pytools/PyLspToolConfigurationMigrationTest.kt @@ -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() + + @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()) + } +} diff --git a/python/python-pytools/tests/testSrc/com/intellij/python/junit5Tests/unit/pytools/PyToolsStateSerializationTest.kt b/python/python-pytools/tests/testSrc/com/intellij/python/junit5Tests/unit/pytools/PyToolsStateSerializationTest.kt index 149d9b5b6554..4aee6a296080 100644 --- a/python/python-pytools/tests/testSrc/com/intellij/python/junit5Tests/unit/pytools/PyToolsStateSerializationTest.kt +++ b/python/python-pytools/tests/testSrc/com/intellij/python/junit5Tests/unit/pytools/PyToolsStateSerializationTest.kt @@ -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)") + } }