PY-90401 Split Pyright tool into separate Pyright and Basedpyright tools

Replace the combined PyrightPyTool (which mixed pyright/basedpyright via
aliases + installInfo) with two PyTool extensions, each with its own
configuration, configurable and icon. Drop the now-unused aliases/installInfo
from PyTool and collapse executableNames to a single executableName; uvx runs
`uvx --from <pkg> <pkg>-langserver`, so the isUvxSupported flag is gone.

Add a PyLspTool<C> base in python-pytools holding the shared LSP-tool wiring
(configuration service, legacy-state migration, FUS snapshot, icon). The pyright
family shares one LSP server: its descriptor binds to the first enabled tool
(basedpyright over pyright), and toggling either restarts the shared provider.

Keep UI off the core abstraction: drop detailConfigurable from PyTool in favour
of a UI-side PyToolDetailConfigurableProvider, and resolve the status-bar icon
from the tool rather than the running server name.

IJ-MR-209748

GitOrigin-RevId: 98ec7f9924af30a86be8cfe71c468800a58a94f9
This commit is contained in:
Vitaly Legchilkin
2026-06-23 19:40:29 +00:00
committed by intellij-monorepo-bot
parent 0555c45ca4
commit fa17ab9390
16 changed files with 108 additions and 86 deletions
@@ -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)
+2
View File
@@ -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",
@@ -36,6 +36,7 @@
<orderEntry type="module" module-name="intellij.python.psi" scope="TEST" />
<orderEntry type="module" module-name="intellij.python.pyproject" scope="TEST" />
<orderEntry type="module" module-name="intellij.python.pytools" scope="TEST" />
<orderEntry type="module" module-name="intellij.python.pytools.ui" scope="TEST" />
<orderEntry type="module" module-name="intellij.python.sdk" scope="TEST" />
<orderEntry type="module" module-name="intellij.python.test.env.junit5" scope="TEST" />
</component>
@@ -18,12 +18,13 @@
<module name="intellij.platform.testFramework.common"/>
<module name="intellij.platform.testFramework.junit5"/>
<module name="intellij.python.black"/>
<module name="intellij.python.community.common"/>
<module name="intellij.python.community"/>
<module name="intellij.python.community.common"/>
<module name="intellij.python.community.impl"/>
<module name="intellij.python.psi"/>
<module name="intellij.python.pyproject"/>
<module name="intellij.python.pytools"/>
<module name="intellij.python.pytools.ui"/>
<module name="intellij.python.sdk"/>
<module name="intellij.python.test.env.junit5"/>
</dependencies>
@@ -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<C : PyLspToolConfiguration<C>>(
protected val project: Project,
private val tool: PyTool,
private val tool: PyLspTool<C>,
) : 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")
@@ -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(", ")
@@ -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)
}
@@ -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
}
@@ -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
@@ -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()
@@ -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
@@ -109,25 +109,10 @@ internal class UvController(
private var attemptedUpgrades: Set<String> = 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 =
@@ -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<PyPackageName> 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
@@ -39,18 +39,17 @@ fun PyTool.isEnabledOn(project: Project): Boolean = getState(project).enabled
suspend fun PyTool.getExecutableWithBaseArgs(
moduleOrProject: ModuleOrProject,
executableNames: List<String> = aliases.map { it.name },
executableName: String = packageName.name,
workingDir: Path? = null,
isUvxSupported: Boolean = true
): PyResult<Pair<BinaryToExec, List<String>>> {
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 <pkg>` only works when the package's entry point matches its name. When the executable
// differs (e.g. pyright → pyright-langserver) uvx needs `--from <pkg> <executable>`.
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<Ve
return versionOutput.parseVersion(packageName.name)
}
private fun EelOsFamily.getOsSpecificBinaryFileNames(executableNames: List<String>): Sequence<String> {
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<String> = 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<String> = 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<String> = 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()
@@ -68,16 +68,8 @@ suspend fun BinaryToExec.getToolVersion(toolVersionPrefix: String): PyResult<Ver
/**
* Convenience: validate a user-supplied custom path for [this] tool against its known names.
* Returns a localized failure if `<path> --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<Version> {
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)
}
@@ -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<C : PyLspToolConfiguration<*>> : 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,
)
}
}