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 0cf2ce1c1f4a..2224ec17194c 100644
--- a/python/python-black/src/com/intellij/python/black/BlackPyTool.kt
+++ b/python/python-black/src/com/intellij/python/black/BlackPyTool.kt
@@ -9,6 +9,7 @@ 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.pytools.ui.PyToolDetailConfigurableProvider
import com.intellij.python.black.PyBlackBundle.message
import com.intellij.python.black.configuration.BlackFormatterConfigurable
import com.intellij.python.black.configuration.BlackFormatterConfiguration
@@ -17,7 +18,7 @@ import org.jetbrains.annotations.ApiStatus
import kotlin.io.path.Path
@ApiStatus.Internal
-class BlackPyTool : PyTool {
+class BlackPyTool : PyTool, PyToolDetailConfigurableProvider {
override val presentableName: String = "Black"
override val description: String get() = message("black.tool.description")
override val packageName: PyPackageName = PyPackageName.from("black")
@@ -45,7 +46,7 @@ class BlackPyTool : PyTool {
return entry
}
- override val detailConfigurable: (Project) -> UnnamedConfigurable = ::BlackFormatterConfigurable
+ override fun createConfigurable(project: Project): UnnamedConfigurable = BlackFormatterConfigurable(project)
override fun summaryFor(project: Project): String {
val cfg = BlackFormatterConfiguration.getBlackConfiguration(project)
diff --git a/python/python-black/tests/BUILD.bazel b/python/python-black/tests/BUILD.bazel
index 255ce10c74c6..7b561d3b1251 100644
--- a/python/python-black/tests/BUILD.bazel
+++ b/python/python-black/tests/BUILD.bazel
@@ -91,6 +91,8 @@ jvm_library(
"//python/python-pyproject:pyproject_test_lib",
"//python/python-pytools:pytools",
"//python/python-pytools:pytools_test_lib",
+ "//python/python-pytools-ui:pytools-ui",
+ "//python/python-pytools-ui:pytools-ui_test_lib",
"//python/python-sdk:sdk",
"//python/python-sdk:sdk_test_lib",
"//python/python-test-env/junit5",
diff --git a/python/python-black/tests/intellij.python.black.tests.iml b/python/python-black/tests/intellij.python.black.tests.iml
index d26e44b0cda6..68d7f6d04db7 100644
--- a/python/python-black/tests/intellij.python.black.tests.iml
+++ b/python/python-black/tests/intellij.python.black.tests.iml
@@ -36,6 +36,7 @@
+
diff --git a/python/python-black/tests/testResources/intellij.python.black.tests.xml b/python/python-black/tests/testResources/intellij.python.black.tests.xml
index aabb99cc8931..f967cb065d11 100644
--- a/python/python-black/tests/testResources/intellij.python.black.tests.xml
+++ b/python/python-black/tests/testResources/intellij.python.black.tests.xml
@@ -18,12 +18,13 @@
-
+
+
diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyLspToolDetailConfigurable.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyLspToolDetailConfigurable.kt
index 2e07897cbba0..740620a7eae3 100644
--- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyLspToolDetailConfigurable.kt
+++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyLspToolDetailConfigurable.kt
@@ -2,11 +2,10 @@
package com.intellij.python.pytools.ui
import com.intellij.openapi.options.BoundConfigurable
-import com.intellij.openapi.options.Configurable
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.DialogPanel
-import com.intellij.python.pytools.PyTool
-import com.intellij.python.pytools.lsp.PyLspToolSettings
+import com.intellij.python.pytools.lsp.PyLspTool
+import com.intellij.python.pytools.lsp.PyLspToolConfiguration
import com.intellij.python.pytools.statistics.PyToolActionSource
import com.intellij.python.pytools.statistics.PyToolUsagesCollector
import com.intellij.ui.dsl.builder.Panel
@@ -14,21 +13,22 @@ import com.intellij.ui.dsl.builder.panel
/**
* Shared base for every LSP-backed tool's detail configurable. Subclasses supply only the
- * per-project [settings] service, an optional [inlayHintLabel] override (Pyright uses a
- * tool-specific string), and optional tool-specific rows via [extraRows] (Ruff uses this for
+ * concrete [PyLspTool] (whose [PyLspTool.configuration] supplies [settings]), an optional
+ * [inlayHintLabel] override, and optional tool-specific rows via [extraRows] (Ruff uses this for
* its `formatting` and `sortImports` checkboxes).
*
* Logging happens automatically in [apply] via [PyToolUsagesCollector.Helper.logConfigurationChanged],
- * which pulls field values from [PyTool.configurationFusSnapshot]. A new tool that extends this
+ * which pulls field values from the tool's `configurationFusSnapshot`. A new tool that extends this
* base therefore cannot accidentally skip FUS reporting — there is no per-subclass `apply` to
* forget.
*/
-abstract class PyLspToolDetailConfigurable(
+abstract class PyLspToolDetailConfigurable>(
protected val project: Project,
- private val tool: PyTool,
+ private val tool: PyLspTool,
) : BoundConfigurable(tool.presentableName) {
- protected abstract val settings: PyLspToolSettings
+ /** This tool's settings, taken straight from [PyLspTool.configuration] (the single source). */
+ protected val settings: C get() = tool.configuration(project)
/** Label used for the inlay-hint row. Override when the tool needs a custom string. */
protected open val inlayHintLabel: String = PyToolsUiBundle.message("checkbox.inlay.hints")
diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyToolDetailConfigurableProvider.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyToolDetailConfigurableProvider.kt
new file mode 100644
index 000000000000..e8fa63aab7a2
--- /dev/null
+++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyToolDetailConfigurableProvider.kt
@@ -0,0 +1,27 @@
+// 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.pytools.ui
+
+import com.intellij.openapi.options.UnnamedConfigurable
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.NlsSafe
+import com.intellij.python.pytools.lsp.PyLspToolSettings
+
+/**
+ * Implemented by a `com.intellij.python.pytools.PyTool` that contributes a detail panel to the
+ * External Tools table's Edit dialog. Kept out of `PyTool` itself so the core tool abstraction stays
+ * free of UI types; the table discovers it with `tool as? PyToolDetailConfigurableProvider`.
+ */
+interface PyToolDetailConfigurableProvider {
+ fun createConfigurable(project: Project): UnnamedConfigurable
+}
+
+/**
+ * Shared comma-separated summary of the standard LSP feature toggles, for `PyTool.summaryFor`.
+ * Tools with extra toggles (e.g. Ruff) build their own.
+ */
+fun pyLspToolFeaturesSummary(settings: PyLspToolSettings): @NlsSafe String = buildList {
+ if (settings.inspections) add(PyToolsUiBundle.message("checkbox.inspections"))
+ if (settings.completions == true) add(PyToolsUiBundle.message("checkbox.completions"))
+ if (settings.inlayHints == true) add(PyToolsUiBundle.message("checkbox.inlay.hints"))
+ if (settings.documentation == true) add(PyToolsUiBundle.message("checkbox.documentation"))
+}.joinToString(", ")
diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PythonPackageManagerExt.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PythonPackageManagerExt.kt
index 58ca5a762462..59c1865947d3 100644
--- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PythonPackageManagerExt.kt
+++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PythonPackageManagerExt.kt
@@ -6,6 +6,6 @@ import com.jetbrains.python.packaging.management.PythonPackageManager
import com.jetbrains.python.packaging.management.getInstalledPackageSnapshot
fun PythonPackageManager.getInstalledToolPackage(pyTool: PyTool): PythonPackage? {
- return pyTool.aliases.firstNotNullOfOrNull { getInstalledPackageSnapshot(it.name) }
+ return getInstalledPackageSnapshot(pyTool.packageName.name)
}
diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsCellRenderers.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsCellRenderers.kt
index 772b12c18e55..f42b3f90f75b 100644
--- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsCellRenderers.kt
+++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsCellRenderers.kt
@@ -176,7 +176,7 @@ internal class ToolCellRenderer(private val host: ToolCellHost) : JPanel(null),
// handler exits early, and the cursor stays default. Same rule mirrored in
// hover hit-testing and the table mouseClicked listener.
paintGear = (row == host.hoveredRow) && (toolRow?.staged?.enabled == true)
- gearEnabled = toolRow?.tool?.detailConfigurable != null
+ gearEnabled = toolRow?.detailConfigurableProvider != null
return this
}
diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsRowModel.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsRowModel.kt
index 6cea233689d7..1e5b249a162a 100644
--- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsRowModel.kt
+++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsRowModel.kt
@@ -22,6 +22,7 @@ import com.intellij.python.pytools.findExecutableInPath
import com.intellij.python.pytools.findExecutableInSdk
import com.jetbrains.python.sdk.pyInterpreterPresentation
import com.intellij.python.pytools.ui.PyToolsUiBundle
+import com.intellij.python.pytools.ui.PyToolDetailConfigurableProvider
import com.intellij.python.pytools.ui.icons.PythonPytoolsUIIcons
import com.jetbrains.python.Result
import com.intellij.python.pytools.validateCustomPath
@@ -91,7 +92,10 @@ internal class ToolRow(
* glyph next to `Sdk`, plus a tooltip listing the resolved binary path per SDK.
*/
var sdkAvailability: SdkAvailability? = null,
-)
+) {
+ /** This tool's detail-panel provider, or `null` when the tool has no detail configurable. */
+ val detailConfigurableProvider: PyToolDetailConfigurableProvider? = tool as? PyToolDetailConfigurableProvider
+}
/**
* Project-SDK detection snapshot for one [ToolRow]: an ordered list of SDKs with the tool's
diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTable.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTable.kt
index b841a8e98d21..31b8d5441cf2 100644
--- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTable.kt
+++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTable.kt
@@ -241,7 +241,7 @@ internal class PyExternalToolsTable(
// opens the detail dialog. Disabled tools surface no actions — including double
// click — to match the missing gear icon and keep the row visually inert.
val row = rows[viewRow]
- if (row.tool.detailConfigurable == null || !row.staged.enabled) return
+ if (row.detailConfigurableProvider == null || !row.staged.enabled) return
val onGear = isOverGearIcon(e, viewRow)
if (onGear || e.clickCount == 2) {
openDetailDialog(row)
@@ -304,7 +304,7 @@ internal class PyExternalToolsTable(
// visibly behaves like a clickable affordance. Tools without settings get a disabled
// gear and keep the default cursor.
val overGear = effective >= 0 &&
- rows[effective].tool.detailConfigurable != null &&
+ rows[effective].detailConfigurableProvider != null &&
isOverGearIcon(e, effective)
val isIconWithAction = pathIconAtHover(pathEffective).let {
it == PathIconKind.INSTALL || it == PathIconKind.UPGRADE || it == PathIconKind.RESET
@@ -455,7 +455,7 @@ internal class PyExternalToolsTable(
/** Open the per-tool detail dialog for [toolRow]; refresh the row on commit. */
private fun openDetailDialog(toolRow: ToolRow) {
- val configurable = toolRow.detail ?: toolRow.tool.detailConfigurable?.invoke(project) ?: return
+ val configurable = toolRow.detail ?: toolRow.detailConfigurableProvider?.createConfigurable(project) ?: return
toolRow.detail = configurable
val component = configurable.createComponent() ?: return
configurable.reset()
diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTooltips.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTooltips.kt
index 47ba522ea4f9..d350143fe0d8 100644
--- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTooltips.kt
+++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTooltips.kt
@@ -103,7 +103,7 @@ private fun lookupStrategyText(mode: com.intellij.python.pytools.configuration.E
*/
private fun toolColumnTooltip(toolRow: ToolRow, host: TooltipHost, eventX: Int, cellRect: Rectangle): String {
val onGear = isOverIcon(eventX, cellRect, PythonPytoolsUIIcons.Settings.iconWidth)
- if (onGear && toolRow.staged.enabled && toolRow.tool.detailConfigurable != null) {
+ if (onGear && toolRow.staged.enabled && toolRow.detailConfigurableProvider != null) {
return PyToolsUiBundle.message("settings.external.tools.edit.tooltip", toolRow.tool.presentableName)
}
// Match the cell-rendering rule: a disabled tool's options aren't surfaced anywhere — its
diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/UvController.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/UvController.kt
index 45a1ac76182d..6a6ccb4295a3 100644
--- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/UvController.kt
+++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/UvController.kt
@@ -109,25 +109,10 @@ internal class UvController(
private var attemptedUpgrades: Set = emptySet()
/**
- * Package name uv knows this row's tool by — a single value, never a set. Derived from the
- * basename of the detected binary, matched against the tool's `aliases`: for Pyright
- * (aliases `[pyright, basedpyright]`) this resolves to whichever of the two was actually
- * found on disk, and every uv operation (lookup, install/upgrade, attempted-upgrade tracking)
- * targets that same package. When nothing is detected the row is showing INSTALL and we fall
- * back to `installInfo.packageName.name` — the package the INSTALL click would put on disk.
+ * Package name uv knows this row's tool by. Each tool maps 1:1 to a single PyPI package, so every uv
+ * operation (lookup, install/upgrade, attempted-upgrade tracking) targets the tool's package name.
*/
- private fun ToolRow.uvPackageName(): String {
- val detectedPath = when (val pfv = pathFieldValue) {
- is PathFieldValue.Custom -> pfv.path
- is PathFieldValue.AutoDetected -> pfv.path
- else -> null
- }
- if (detectedPath != null) {
- val baseName = detectedPath.fileName.toString().removeSuffix(".exe")
- tool.aliases.firstOrNull { it.name == baseName }?.let { return it.name }
- }
- return tool.installInfo.packageName.name
- }
+ private fun ToolRow.uvPackageName(): String = tool.packageName.name
/** True iff the detected package is currently installed by uv (per the cached list). */
fun isUvManaged(toolRow: ToolRow): Boolean =
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 d827dd9f690d..4de9cfb24115 100644
--- a/python/python-pytools/src/com/intellij/python/pytools/PyTool.kt
+++ b/python/python-pytools/src/com/intellij/python/pytools/PyTool.kt
@@ -2,7 +2,6 @@
package com.intellij.python.pytools
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.statistics.PyToolFusSnapshot
@@ -10,15 +9,9 @@ import com.jetbrains.python.packaging.PyPackageName
import org.jetbrains.annotations.Nls
import com.intellij.openapi.util.Version as PlatformVersion
-data class InstallInfo(
- val packageName: PyPackageName,
- val installHelp: @Nls String? = null,
-)
-
interface PyTool {
val presentableName: @NlsSafe String
val packageName: PyPackageName
- val aliases: List get() = listOf(packageName)
/**
* One-line user-facing description of the tool (e.g. "Linter and code formatter for Python").
@@ -27,8 +20,6 @@ interface PyTool {
*/
val description: @Nls String
- val installInfo: InstallInfo get() = InstallInfo(packageName)
-
/**
* Provides a unique identifier (python package name) for the feature usage statistics (FUS) system.
* The identifier is dynamically derived from the first package name in the list of known package names.
@@ -54,13 +45,6 @@ interface PyTool {
*/
fun migrateLegacyState(project: Project): PyToolsState.ToolEntry? = null
- /**
- * Factory that builds the per-tool detail UI shown in the Edit dialog of the External Tools
- * table. `null` (the default) means the tool has no extra options — the table uses this to dim
- * and disable the gear icon for the row.
- */
- val detailConfigurable: ((Project) -> UnnamedConfigurable)? get() = null
-
/**
* Compact, comma-separated summary of currently-activated features for the External Tools table
* (e.g. "Inspections, Formatting"). Returning an empty string hides the cell content for the
diff --git a/python/python-pytools/src/com/intellij/python/pytools/PyToolExt.kt b/python/python-pytools/src/com/intellij/python/pytools/PyToolExt.kt
index 4d92c0b1451a..01d19c7f2e13 100644
--- a/python/python-pytools/src/com/intellij/python/pytools/PyToolExt.kt
+++ b/python/python-pytools/src/com/intellij/python/pytools/PyToolExt.kt
@@ -39,18 +39,17 @@ fun PyTool.isEnabledOn(project: Project): Boolean = getState(project).enabled
suspend fun PyTool.getExecutableWithBaseArgs(
moduleOrProject: ModuleOrProject,
- executableNames: List = aliases.map { it.name },
+ executableName: String = packageName.name,
workingDir: Path? = null,
- isUvxSupported: Boolean = true
): PyResult>> {
val state = getState(moduleOrProject.project)
val toolBinaryPath = when (state.discoveryMode) {
ExecutableDiscoveryMode.INTERPRETER -> {
val pyRichSdk = moduleOrProject.moduleIfExists?.pythonSdk?.pyRichSdk()
- pyRichSdk?.let { findExecutableInSdk(it, executableNames) } ?: findExecutableInPath(state, executableNames)
+ pyRichSdk?.let { findExecutableInSdk(it, executableName) } ?: findExecutableInPath(state, executableName)
}
- ExecutableDiscoveryMode.PATH -> findExecutableInPath(state, executableNames)
+ ExecutableDiscoveryMode.PATH -> findExecutableInPath(state, executableName)
ExecutableDiscoveryMode.UVX -> null
}
@@ -62,14 +61,13 @@ suspend fun PyTool.getExecutableWithBaseArgs(
BinOnEel(toolBinaryPath, workDir = workDir).let { PyResult.success(it to emptyList()) }
}
else {
- if (!isUvxSupported) {
- return PyResult.localizedError(message("uvx.is.not.installed"))
- }
-
val uvxPath = localEel.exec.where("uvx")
?: return PyResult.localizedError(message("uvx.is.not.installed"))
- val uvxArgs = listOf(packageName.name)
+ // `uvx ` only works when the package's entry point matches its name. When the executable
+ // differs (e.g. pyright → pyright-langserver) uvx needs `--from `.
+ val uvxArgs = if (executableName == packageName.name) listOf(executableName)
+ else listOf("--from", packageName.name, executableName)
BinOnEel(uvxPath.asNioPath(), workDir = workDir).let { PyResult.success(it to uvxArgs) }
}
}
@@ -116,11 +114,6 @@ suspend fun PyTool.resolveVersion(moduleOrProject: ModuleOrProject): PyResult): Sequence {
- return executableNames.asSequence().map { getOsSpecificBinaryName(it) }
-}
-
-
private fun EelOsFamily.getOsSpecificBinaryName(binaryName: String): String = when (this) {
EelOsFamily.Posix -> binaryName
EelOsFamily.Windows -> "$binaryName.exe"
@@ -129,22 +122,18 @@ private fun EelOsFamily.getOsSpecificBinaryName(binaryName: String): String = wh
/**
* only local sdks are supported currently
*/
-fun PyTool.findExecutableInSdk(pyRichSdk: PyRichSdk, executableNames: List = aliases.map { it.name }): Path? {
+fun PyTool.findExecutableInSdk(pyRichSdk: PyRichSdk, executableName: String = packageName.name): Path? {
return pyRichSdk.pythonBinaryPath?.let { basePythonBinaryPath ->
val osFamily = basePythonBinaryPath.getEelDescriptor().osFamily
- osFamily.getOsSpecificBinaryFileNames(executableNames).firstNotNullOfOrNull { binaryFileName ->
- basePythonBinaryPath.resolveSibling(binaryFileName).takeIf { it.isExecutable() }
- }
+ basePythonBinaryPath.resolveSibling(osFamily.getOsSpecificBinaryName(executableName)).takeIf { it.isExecutable() }
}
}
-private fun PyTool.findExecutableInPath(state: PyToolsState.ToolEntry, executableNames: List = aliases.map { it.name }): Path? {
- return state.customToolBinaryPath ?: findExecutableInPath(executableNames)
+private fun PyTool.findExecutableInPath(state: PyToolsState.ToolEntry, executableName: String = packageName.name): Path? {
+ return state.customToolBinaryPath ?: findExecutableInPath(executableName)
}
fun PyTool.findExecutableInPath(
- executableNames: List = aliases.map { it.name },
+ executableName: String = packageName.name,
osFamily: EelOsFamily = LocalEelDescriptor.osFamily,
-): Path? = osFamily.getOsSpecificBinaryFileNames(executableNames).firstNotNullOfOrNull {
- PathEnvironmentVariableUtil.findInPath(it)?.toPath()
-}
+): Path? = PathEnvironmentVariableUtil.findInPath(osFamily.getOsSpecificBinaryName(executableName))?.toPath()
diff --git a/python/python-pytools/src/com/intellij/python/pytools/PyToolVersionCheck.kt b/python/python-pytools/src/com/intellij/python/pytools/PyToolVersionCheck.kt
index fe2cdaef4c70..6bfb58c74841 100644
--- a/python/python-pytools/src/com/intellij/python/pytools/PyToolVersionCheck.kt
+++ b/python/python-pytools/src/com/intellij/python/pytools/PyToolVersionCheck.kt
@@ -68,16 +68,8 @@ suspend fun BinaryToExec.getToolVersion(toolVersionPrefix: String): PyResult --version` does not match.
- *
- * The expected version-prefix is derived from [path]'s basename matched against [PyTool.aliases] —
- * required for tools where the primary [PyTool.packageName] differs from the actually-installed
- * alias (e.g. Pyright is integrated as `pyright` but `basedpyright --version` prints
- * `basedpyright X.Y.Z`, which would never match the `pyright` prefix). When the basename
- * matches no known alias we fall back to the primary [PyTool.packageName] for backward compatibility.
*/
@ApiStatus.Internal
suspend fun PyTool.validateCustomPath(path: Path): PyResult {
- val baseName = path.fileName.toString().removeSuffix(".exe")
- val versionPrefix = aliases.firstOrNull { it.name == baseName }?.name ?: packageName.name
- return BinOnEel(path).getToolVersion(versionPrefix)
+ return BinOnEel(path).getToolVersion(packageName.name)
}
diff --git a/python/python-pytools/src/com/intellij/python/pytools/lsp/PyLspTool.kt b/python/python-pytools/src/com/intellij/python/pytools/lsp/PyLspTool.kt
new file mode 100644
index 000000000000..29185c9a1691
--- /dev/null
+++ b/python/python-pytools/src/com/intellij/python/pytools/lsp/PyLspTool.kt
@@ -0,0 +1,36 @@
+// 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.pytools.lsp
+
+import com.intellij.openapi.project.Project
+import com.intellij.python.pytools.PyTool
+import com.intellij.python.pytools.PyToolsState
+import com.intellij.python.pytools.statistics.PyToolFusSnapshot
+import javax.swing.Icon
+
+/**
+ * Base for every LSP-backed [PyTool]. Captures the shared, non-UI wiring around a per-project
+ * [PyLspToolConfiguration] — its [configuration] service is the single source of the tool's settings,
+ * used here for legacy-state migration and the FUS snapshot, and by the detail UI in the UI module.
+ *
+ * UI concerns (the detail configurable, the features summary) deliberately live in the UI layer and
+ * are not part of this base.
+ */
+abstract class PyLspTool> : PyTool {
+ /** Per-project settings service backing this tool — the single source of its configuration. */
+ abstract fun configuration(project: Project): C
+
+ /** Icon shown for this tool's LSP server (status-bar widget, advertiser notification, ...). */
+ abstract val icon: Icon
+
+ override fun migrateLegacyState(project: Project): PyToolsState.ToolEntry = configuration(project).migrateToPyToolState()
+
+ override fun configurationFusSnapshot(project: Project): PyToolFusSnapshot {
+ val cfg = configuration(project)
+ return super.configurationFusSnapshot(project).copy(
+ inspections = cfg.inspections,
+ completions = cfg.completions,
+ inlayHints = cfg.inlayHints,
+ documentation = cfg.documentation,
+ )
+ }
+}