PY-90726 install Python CLI tools via uv when available

Add a pluggable PyToolManagerProvider EP (PyToolManager: uv tool install, pip
fallback) and PyTool models for uv, hatch, poetry, pipenv. Route the Add
Interpreter, pyproject, and External Tools flows through
PyTool.performToolInstallation. Prefer uv (latest, isolated) over pip and drop
the pinned Poetry 1.8.0.

IJ-MR-211863

GitOrigin-RevId: 52cd9e4bd22303c1a6a7d9a27059ffca6120cd73
This commit is contained in:
Vitaly Legchilkin
2026-07-06 21:26:57 +00:00
committed by intellij-monorepo-bot
parent 39aa5467fd
commit ca1cb3145a
64 changed files with 779 additions and 388 deletions
+2
View File
@@ -513,6 +513,7 @@ jvm_library(
"//python/python-uv/backend",
"//platform/testIntegration",
"//platform/testIntegration-ui",
"//python/python-poetry/backend",
],
)
@@ -669,6 +670,7 @@ jvm_library(
"//python/python-uv/backend:backend_test_lib",
"//platform/testIntegration:testIntegration_test_lib",
"//platform/testIntegration-ui:testIntegration-ui_test_lib",
"//python/python-poetry/backend:backend_test_lib",
],
)
### auto-generated section `build intellij.python.community.impl` end
@@ -208,5 +208,6 @@
<orderEntry type="module" module-name="intellij.python.uv.backend" />
<orderEntry type="module" module-name="intellij.platform.testIntegration" />
<orderEntry type="module" module-name="intellij.platform.testIntegration.ui" />
<orderEntry type="module" module-name="intellij.python.community.impl.poetry.backend" />
</component>
</module>
+4
View File
@@ -28,6 +28,8 @@ jvm_library(
"//python/python-sdk:sdk",
"//platform/util",
"//python/python-process-output/common",
"//python/openapi:community",
"//python/python-pytools:pytools",
],
)
@@ -47,6 +49,8 @@ jvm_library(
"//python/python-sdk:sdk_test_lib",
"//platform/util:util_test_lib",
"//python/python-process-output/common:common_test_lib",
"//python/openapi:community_test_lib",
"//python/python-pytools:pytools_test_lib",
],
)
### auto-generated section `build intellij.python.community.impl.pipenv` end
@@ -15,5 +15,7 @@
<orderEntry type="module" module-name="intellij.python.sdk" />
<orderEntry type="module" module-name="intellij.platform.util" />
<orderEntry type="module" module-name="intellij.python.processOutput.common" />
<orderEntry type="module" module-name="intellij.python.community" />
<orderEntry type="module" module-name="intellij.python.pytools" />
</component>
</module>
@@ -2,8 +2,11 @@
<dependencies>
<module name="intellij.python.sdk"/>
<module name="intellij.python.processOutput.common"/>
<module name="intellij.python.community"/>
<module name="intellij.python.pytools"/>
</dependencies>
<extensions defaultExtensionNs="com.intellij">
<python.pytools.pyTool implementation="com.intellij.python.community.impl.pipenv.PipEnvPyTool"/>
<python.processOutput.common.processOutputIconMapping implementation="com.intellij.python.community.impl.pipenv.PipenvIconMapping"/>
</extensions>
</idea-plugin>
@@ -0,0 +1 @@
python.pipenv.tool.description=A Python dependency and virtual environment manager.
@@ -0,0 +1,28 @@
// 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.community.impl.pipenv
import com.intellij.python.community.impl.pipenv.PyPipenvBundle.message
import com.intellij.python.community.impl.pipenv.icons.PythonCommunityImplPipenvIcons
import com.intellij.python.pytools.PyTool
import com.jetbrains.python.packaging.PyPackageName
import org.jetbrains.annotations.ApiStatus
import javax.swing.Icon
/**
* [Pipenv](https://pipenv.pypa.io/) — a Python dependency and virtual-environment manager maintained
* under the PyPA. It combines pip and virtualenv into a single workflow, tracking declared and locked
* dependencies in `Pipfile` and `Pipfile.lock` and creating a per-project virtual environment.
*/
@ApiStatus.Internal
class PipEnvPyTool : PyTool {
override val presentableName: String = "Pipenv"
override val packageName: PyPackageName = PyPackageName.from("pipenv")
override val description: String get() = message("python.pipenv.tool.description")
// TODO: Provide a special icon for pipenv
override val icon: Icon get() = PythonCommunityImplPipenvIcons.PythonClosed
@Suppress("CompanionObjectInExtension")
companion object {
fun getInstance(): PipEnvPyTool = PyTool.EP_NAME.findExtensionOrFail(PipEnvPyTool::class.java)
}
}
@@ -8,6 +8,6 @@ import com.intellij.python.processOutput.common.ProcessOutputIconMapping
internal class PipenvIconMapping : ProcessOutputIconMapping() {
override val mapping: Map<ProcessBinaryFileName, ProcessIcon> = mapOf(
ProcessBinaryFileName("pipenv") to ProcessIcon(PIPENV_ICON, PythonCommunityImplPipenvIcons::class.java)
ProcessBinaryFileName("pipenv") to ProcessIcon(PythonCommunityImplPipenvIcons.PythonClosed, PythonCommunityImplPipenvIcons::class.java)
)
}
@@ -0,0 +1,16 @@
// 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.community.impl.pipenv
import com.intellij.DynamicBundle
import org.jetbrains.annotations.Nls
import org.jetbrains.annotations.PropertyKey
internal object PyPipenvBundle {
private const val BUNDLE = "messages.PyPipenvBundle"
private val INSTANCE = DynamicBundle(PyPipenvBundle::class.java, BUNDLE)
fun message(key: @PropertyKey(resourceBundle = BUNDLE) String, vararg params: Any): @Nls String {
return INSTANCE.getMessage(key, *params)
}
}
@@ -4,5 +4,5 @@ package com.intellij.python.community.impl.pipenv
import com.intellij.python.community.impl.pipenv.icons.PythonCommunityImplPipenvIcons
import javax.swing.Icon
// TODO: Provide a special icon for pipenv
@Deprecated("This icon is not the icon of pipenv")
val PIPENV_ICON: Icon = PythonCommunityImplPipenvIcons.PythonClosed
@@ -12,8 +12,8 @@
<module name="intellij.platform.structureView"/>
<module name="intellij.platform.testRunner"/>
<module name="intellij.platform.ui.jcef"/>
<module name="intellij.python.community.common"/>
<module name="intellij.python.community"/>
<module name="intellij.python.community.common"/>
<module name="intellij.python.community.core.impl"/>
<module name="intellij.python.community.execService"/>
<module name="intellij.python.community.execService.python"/>
@@ -21,6 +21,7 @@
<module name="intellij.python.community.impl.conda"/>
<module name="intellij.python.community.impl.installer"/>
<module name="intellij.python.community.impl.pipenv"/>
<module name="intellij.python.community.impl.poetry.backend"/>
<module name="intellij.python.community.impl.poetry.common"/>
<module name="intellij.python.community.interpreters"/>
<module name="intellij.python.community.services.systemPython"/>
@@ -49,6 +50,7 @@
<extensions defaultExtensionNs="com.intellij">
<applicationService serviceInterface="com.jetbrains.python.errorProcessing.ErrorSink"
serviceImplementation="com.jetbrains.python.impl.ShowingMessageErrorSink"/>
<python.pytools.pyToolManagerProvider implementation="com.jetbrains.python.sdk.configuration.SystemPythonToolManagerProvider"/>
<statusBarWidgetFactory id="pythonDependencyFileWidget"
implementation="com.jetbrains.python.requirements.widget.PyDependenciesFileStatusBarWidgetFactory"
order="after pythonInterpreterWidget, before git, before hg, before Notifications"/>
@@ -563,7 +563,6 @@ sdk.create.binary.not.executable=Binary is not executable
sdk.create.executable.directory.error=Path cannot be a directory
sdk.create.tooltip.browse=Browse\u2026
sdk.create.custom.venv.install.fix.title=Install {0}
sdk.create.custom.venv.install.fix.title.using.pip=Install {0} using pip
sdk.create.custom.venv.run.error.message=Error Running {0}
sdk.create.custom.venv.progress.title.detect.executable=Detect executable
sdk.create.custom.existing.env.title=Environment:
@@ -8,20 +8,27 @@ 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.icons.PythonPyToolsIcons
import com.intellij.python.pytools.configuration.ExecutableDiscoveryMode
import com.intellij.python.pytools.ui.PyToolDetailConfigurableProvider
import com.intellij.python.pytools.configuration.ConfigurablePyTool
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 javax.swing.Icon
import kotlin.io.path.Path
/**
* [Black](https://black.readthedocs.io/) — the uncompromising Python code formatter maintained under
* the PSF. It reformats source into a single, consistent style, leaving little to configure.
*/
@ApiStatus.Internal
class BlackPyTool : PyTool, PyToolDetailConfigurableProvider {
class BlackPyTool : PyTool, ConfigurablePyTool {
override val presentableName: String = "Black"
override val description: String get() = message("black.tool.description")
override val packageName: PyPackageName = PyPackageName.from("black")
override val icon: Icon get() = PythonPyToolsIcons.Logo
/**
* `--line-ranges` (fragment formatting) requires Black 23.11.0; older versions cannot honour
@@ -22,6 +22,7 @@
<pyprojectmanager implementation="com.intellij.python.hatch.impl.HatchPyProjectManager"/>
</extensions>
<extensions defaultExtensionNs="com.intellij">
<python.pytools.pyTool implementation="com.intellij.python.hatch.HatchPyTool"/>
<python.community.common.toolToIconMapper implementation="com.intellij.python.hatch.impl.HatchIdMapper"/>
<python.processOutput.common.processOutputIconMapping implementation="com.intellij.python.hatch.impl.HatchIconMapping"/>
</extensions>
@@ -1,3 +1,4 @@
python.hatch.tool.description=A modern, extensible Python project manager.
python.hatch.error.executable.is.not.found=Hatch executable is not found at: {0}
python.hatch.error.base.python.executable.is.not.found=Base python executable is not found at: {0}
python.hatch.error.environment.creation=Environment creation failure: {0}
@@ -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.hatch
import com.intellij.python.hatch.PyHatchBundle.message
import com.intellij.python.hatch.icons.PythonHatchIcons
import com.intellij.python.pytools.PyTool
import com.jetbrains.python.packaging.PyPackageName
import org.jetbrains.annotations.ApiStatus
import javax.swing.Icon
/**
* [Hatch](https://hatch.pypa.io/) — a modern, extensible Python project manager maintained under the
* PyPA. It manages isolated and matrix environments, builds distributions through its Hatchling build
* backend, bumps and manages project versions, publishes packages to PyPI, and runs project scripts.
*/
@ApiStatus.Internal
class HatchPyTool : PyTool {
override val presentableName: String = "Hatch"
override val packageName: PyPackageName = PyPackageName.from("hatch")
override val description: String get() = message("python.hatch.tool.description")
override val icon: Icon get() = PythonHatchIcons.Logo
@Suppress("CompanionObjectInExtension")
companion object {
fun getInstance(): HatchPyTool = PyTool.EP_NAME.findExtensionOrFail(HatchPyTool::class.java)
}
}
+12
View File
@@ -27,6 +27,12 @@ jvm_library(
"//python/python-pyproject:pyproject",
"//platform/util/base/multiplatform",
"//python/common",
"//platform/util/jdom",
"//python/python-sdk:sdk",
"//platform/projectModel-api:projectModel",
"//python/python-pytools:pytools",
"//platform/core-api:core",
"//platform/util",
],
)
@@ -47,6 +53,12 @@ jvm_library(
"//python/python-pyproject:pyproject_test_lib",
"//platform/util/base/multiplatform:multiplatform_test_lib",
"//python/common:common_test_lib",
"//platform/util/jdom:jdom_test_lib",
"//python/python-sdk:sdk_test_lib",
"//platform/projectModel-api:projectModel_test_lib",
"//python/python-pytools:pytools_test_lib",
"//platform/core-api:core_test_lib",
"//platform/util:util_test_lib",
],
)
### auto-generated section `build intellij.python.community.impl.poetry.backend` end
@@ -15,5 +15,11 @@
<orderEntry type="module" module-name="intellij.python.pyproject" />
<orderEntry type="module" module-name="intellij.platform.util.base.multiplatform" />
<orderEntry type="module" module-name="intellij.python.community.common" />
<orderEntry type="module" module-name="intellij.platform.util.jdom" />
<orderEntry type="module" module-name="intellij.python.sdk" />
<orderEntry type="module" module-name="intellij.platform.projectModel" />
<orderEntry type="module" module-name="intellij.python.pytools" />
<orderEntry type="module" module-name="intellij.platform.core" />
<orderEntry type="module" module-name="intellij.platform.util" />
</component>
</module>
@@ -7,8 +7,12 @@
<module name="intellij.platform.backend"/>
<module name="intellij.python.community.impl.poetry.common"/>
<module name="intellij.python.community.common"/>
<module name="intellij.python.pytools"/>
</dependencies>
<extensions defaultExtensionNs="com.intellij.python.pyproject.model">
<pyprojectmanager implementation="com.intellij.python.community.impl.poetry.backend.PoetryPyProjectManager"/>
</extensions>
<extensions defaultExtensionNs="com.intellij">
<python.pytools.pyTool implementation="com.intellij.python.community.impl.poetry.backend.PoetryPyTool"/>
</extensions>
</idea-plugin>
@@ -0,0 +1 @@
python.poetry.tool.description=A tool for Python dependency management and packaging.
@@ -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.community.impl.poetry.backend
import com.intellij.python.community.impl.poetry.backend.PyPoetryBundle.message
import com.intellij.python.community.impl.poetry.common.icons.PythonCommunityImplPoetryCommonIcons
import com.intellij.python.pytools.PyTool
import com.jetbrains.python.packaging.PyPackageName
import org.jetbrains.annotations.ApiStatus
import javax.swing.Icon
/**
* [Poetry](https://python-poetry.org/) — a tool for Python dependency management and packaging. It
* declares dependencies in `pyproject.toml`, resolves them into a `poetry.lock` file for reproducible
* installs, manages the project's virtual environment, and builds and publishes packages.
*/
@ApiStatus.Internal
class PoetryPyTool : PyTool {
override val presentableName: String = "Poetry"
override val packageName: PyPackageName = PyPackageName.from("poetry")
override val description: String get() = message("python.poetry.tool.description")
override val icon: Icon get() = PythonCommunityImplPoetryCommonIcons.Poetry
@Suppress("CompanionObjectInExtension")
companion object {
fun getInstance(): PoetryPyTool = PyTool.EP_NAME.findExtensionOrFail(PoetryPyTool::class.java)
}
}
@@ -0,0 +1,16 @@
// 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.community.impl.poetry.backend
import com.intellij.DynamicBundle
import org.jetbrains.annotations.Nls
import org.jetbrains.annotations.PropertyKey
internal object PyPoetryBundle {
private const val BUNDLE = "messages.PyPoetryBundle"
private val INSTANCE = DynamicBundle(PyPoetryBundle::class.java, BUNDLE)
fun message(key: @PropertyKey(resourceBundle = BUNDLE) String, vararg params: Any): @Nls String {
return INSTANCE.getMessage(key, *params)
}
}
+4
View File
@@ -27,6 +27,8 @@ jvm_library(
"//platform/util:util-ui",
"//platform/core-api:core",
"//platform/core-ui",
"//platform/eel",
"//platform/eel-provider",
"//platform/ide-core",
"//platform/platform-impl:ide-impl",
"//platform/observable",
@@ -64,6 +66,8 @@ jvm_library(
"//platform/util:util-ui_test_lib",
"//platform/core-api:core_test_lib",
"//platform/core-ui:core-ui_test_lib",
"//platform/eel:eel_test_lib",
"//platform/eel-provider:eel-provider_test_lib",
"//platform/ide-core:ide-core_test_lib",
"//platform/platform-impl:ide-impl_test_lib",
"//platform/observable:observable_test_lib",
@@ -14,6 +14,8 @@
<orderEntry type="module" module-name="intellij.platform.util.ui" />
<orderEntry type="module" module-name="intellij.platform.core" />
<orderEntry type="module" module-name="intellij.platform.core.ui" />
<orderEntry type="module" module-name="intellij.platform.eel" />
<orderEntry type="module" module-name="intellij.platform.eel.provider" />
<orderEntry type="module" module-name="intellij.platform.ide.core" />
<orderEntry type="module" module-name="intellij.platform.ide.impl" />
<orderEntry type="module" module-name="intellij.platform.ide.observable" />
@@ -1,37 +1,15 @@
settings.installation.group=Installation
settings.installation.type.label=Type:
settings.installation.type.binary=Installed as binary
settings.installation.type.uv=Managed by uv
settings.installation.type.unavailable=This installation type is stored in settings but is currently not available. Please choose another type.
settings.installation.interpreter.label=Python interpreter:
settings.installation.executable.auto.detected.path=Auto-detected: {0}
execution.mode.label=Execution mode:
execution.mode.package=Interpreter
execution.mode.binary=Path
executable.label=Executable:
select.path.to.executable=Select Path to Executable
tool.executable.not.found=Unable to auto-detect executable in $PATH
dialog.message.must.not.be.empty=Must not be empty
dialog.message.must.be.absolute=Must be absolute
dialog.message.not.found=Not found
dialog.message.must.not.be.directory=Must not be a directory
dialog.message.not.executable=Not executable
python.sdk.rendering.module.default=Module default
python.packaging.list.progress=Reading installed python packages
button.install=Install {0}
label.package.not.installed.in.current.interpreter={0} is not installed
label.tool.version=Tool version:
link.tool.update=Update
settings.external.tools.title=External Tools
settings.external.tools.description=Enable and configure tools, choose a lookup method, and optionally override the path. The tool can be detected from sdk and path, or you can run it manually without installation via the 'uvx \u2039tool\u203A' command. <a href="actionsOnSave">All actions on save\u2026</a>
settings.external.tools.column.name=Tool
settings.external.tools.column.enabled=Enabled
settings.external.tools.column.mode=Lookup
settings.external.tools.column.path=Executable Path
settings.external.tools.path.not.found=Not found in $PATH
@@ -46,28 +24,26 @@ settings.external.tools.lookup.strategy.path=Try the tool from $PATH first, then
settings.external.tools.lookup.strategy.uvx=Run the tool via uvx (no installation required).
settings.external.tools.lookup.sdk.tooltip.header=Project SDKs:
settings.external.tools.lookup.sdk.tooltip.not.installed=not installed
settings.external.tools.install.via.uv.tooltip=Install with `uv tool install`
settings.external.tools.install.via.uv.progress=Installing {0}\u2026
settings.external.tools.install.via.uv.error.title=Could Not Install {0}
settings.external.tools.install.via.uv.success.balloon={0} installed
settings.external.tools.install.via.uv.success.to.version.balloon={0} {1} installed
settings.external.tools.install.via.uv.error.no.uv=The uv executable could not be found.
settings.external.tools.install.tooltip=Install the latest version
settings.external.tools.install.progress=Installing {0}\u2026
settings.external.tools.install.error.title=Could Not Install {0}
settings.external.tools.install.success.balloon={0} installed
settings.external.tools.install.success.to.version.balloon={0} {1} installed
settings.external.tools.path.version.tooltip=Version: {0}
settings.external.tools.path.below.minimum.tooltip=Below minimum: requires {0} {1} or later (detected {2}).
settings.external.tools.path.upgrade.unknown.tooltip=Try to upgrade
settings.external.tools.path.upgrade.to.version.tooltip=Upgrade to {0}
settings.external.tools.path.edit.tooltip=Browse for executable
settings.external.tools.path.reset.tooltip=Use auto-detection
settings.external.tools.upgrade.via.uv.progress=Upgrading {0}\u2026
settings.external.tools.upgrade.via.uv.error.title=Could Not Upgrade {0}
settings.external.tools.upgrade.via.uv.success.balloon={0} upgraded
settings.external.tools.upgrade.via.uv.success.to.version.balloon={0} upgraded to {1}
settings.external.tools.upgrade.via.uv.up.to.date.balloon={0} is already up to date ({1})
settings.external.tools.upgrade.progress=Upgrading {0}\u2026
settings.external.tools.upgrade.error.title=Could Not Upgrade {0}
settings.external.tools.upgrade.success.balloon={0} upgraded
settings.external.tools.upgrade.success.to.version.balloon={0} upgraded to {1}
settings.external.tools.upgrade.up.to.date.balloon={0} is already up to date ({1})
settings.external.tools.uv.hint.not.installed=uv is not installed on your system. install uv to use uvx running mode.
settings.external.tools.uv.hint.install.button=Install uv
settings.external.tools.install.uv.progress=Installing uv\u2026
settings.external.tools.install.uv.error.title=Could Not Install uv
settings.external.tools.install.uv.error.no.python=No system Python interpreter was found.
label.features=Features:
checkbox.inspections=Inspections
@@ -4,6 +4,7 @@ package com.intellij.python.pytools.ui
import com.intellij.ide.ui.search.SearchableOptionContributor
import com.intellij.ide.ui.search.SearchableOptionProcessor
import com.intellij.python.pytools.PyTool
import com.intellij.python.pytools.configuration.ConfigurablePyTool
import com.intellij.python.pytools.ui.configuration.PyExternalToolsConfigurable
/**
@@ -14,7 +15,7 @@ import com.intellij.python.pytools.ui.configuration.PyExternalToolsConfigurable
internal class PyExternalToolsSearchableOptionContributor : SearchableOptionContributor() {
override fun processOptions(processor: SearchableOptionProcessor) {
val displayName = PyToolsUiBundle.message("settings.external.tools.title")
for (tool in PyTool.EP_NAME.extensionList) {
for (tool in PyTool.EP_NAME.extensionList.filter { it is ConfigurablePyTool }) {
processor.addOptions(
tool.presentableName,
null,
@@ -1,20 +1,9 @@
// 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.
@@ -54,7 +54,7 @@ internal interface PathCellHost {
/**
* Status provider used by the Lookup-column renderers to decorate each chain step (SDK, PATH,
* uvx) with a ✓ / ✗ / ◐ glyph. Implemented by the configurable since the renderers don't have
* direct access to the [UvController] or the project SDK list.
* direct access to the [PyToolManagementController] or the project SDK list.
*/
internal interface ModeCellHost {
fun modeStatusFor(toolRow: ToolRow, mode: ExecutableDiscoveryMode): ChainStepStatus
@@ -66,7 +66,6 @@ internal interface ModeCellHost {
* string when the cell value isn't a [ExecutableDiscoveryMode]. Shared between the combobox
* and read-only renderers so both go through the same null-handling path.
*/
@Suppress("HardCodedStringLiteral")
private fun renderChainText(host: ModeCellHost?, table: JTable, value: Any?, row: Int): String {
val mode = value as? ExecutableDiscoveryMode ?: return ""
val toolRow = (table.model as? ListTableModel<*>)?.getRowValue(row) as? ToolRow
@@ -33,10 +33,10 @@ class PyExternalToolsConfigurable(private val project: Project) : BoundSearchabl
/**
* Owns the uv-state snapshot + install/upgrade actions. Its coroutine scope is supplied later by
* [createPanel]'s `launchOnShow` block via [UvController.onShown], so background work lives for
* [createPanel]'s `launchOnShow` block via [PyToolManagementController.onShown], so background work lives for
* the panel's showing-lifetime.
*/
private val uv: UvController = UvController(
private val uv: PyToolManagementController = PyToolManagementController(
project = project,
onStateChanged = ::onUvStateChanged,
refreshRow = ::refreshRow,
@@ -45,12 +45,12 @@ class PyExternalToolsConfigurable(private val project: Project) : BoundSearchabl
/** Initialized once in [createPanel]; every method that touches it is invoked while the UI is live. */
private lateinit var toolsTable: PyExternalToolsTable
/** Wired in as [UvController.onStateChanged]; refresh the table on EDT. The hint footer rebinds via [UvController.uvAvailable]. */
/** Wired in as [PyToolManagementController.onStateChanged]; refresh the table on EDT. The hint footer rebinds via [PyToolManagementController.uvAvailable]. */
private fun onUvStateChanged() {
toolsTable.fireAllRowsChanged()
}
/** Wired in as [UvController.refreshRow]; delegate to the table. */
/** Wired in as [PyToolManagementController.refreshRow]; delegate to the table. */
private fun refreshRow(item: ToolRow) {
toolsTable.refreshRow(item)
}
@@ -22,7 +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.configuration.ConfigurablePyTool
import com.intellij.python.pytools.ui.icons.PythonPytoolsUIIcons
import com.jetbrains.python.Result
import com.intellij.python.pytools.validateCustomPath
@@ -94,7 +94,7 @@ internal class ToolRow(
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
val detailConfigurableProvider: ConfigurablePyTool? = tool as? ConfigurablePyTool
}
/**
@@ -148,15 +148,12 @@ internal enum class PathIconKind(val icon: Icon?) {
/**
* Compute the hover-only icon for a Path cell given the row's current state. The function is
* deliberately pure: the caller supplies the live uv-availability snapshot (`null` while
* detection is in flight) and the "is this tool uv-managed" predicate, so the renderer doesn't
* need to know how those are sourced.
* deliberately pure: the caller supplies the "is an upgrade available" predicate, so the renderer
* doesn't need to know how it is sourced.
*/
internal fun iconKindFor(
toolRow: ToolRow?,
detected: PathFieldValue?,
uvAvailable: Boolean?,
isUvManaged: (ToolRow) -> Boolean,
isUpgradeAvailable: (ToolRow) -> Boolean,
): PathIconKind = when {
toolRow == null -> PathIconKind.NONE
@@ -164,10 +161,11 @@ internal fun iconKindFor(
// action there is "revert to auto-detection". Skip install / upgrade / info — none of them
// apply to a user-pointed-at executable.
detected is PathFieldValue.Custom -> PathIconKind.RESET
detected is PathFieldValue.NotFound && uvAvailable == true -> PathIconKind.INSTALL
detected is PathFieldValue.NotFound -> PathIconKind.NONE
// Offer install for any undiscovered tool; the installer uses uv when present and otherwise
// falls back to a pip install into a system Python.
detected is PathFieldValue.NotFound -> PathIconKind.INSTALL
toolRow.version == null -> PathIconKind.NONE
isUvManaged(toolRow) && isUpgradeAvailable(toolRow) -> PathIconKind.UPGRADE
isUpgradeAvailable(toolRow) -> PathIconKind.UPGRADE
// Otherwise no actionable icon — the path text + version tooltip already conveys the state.
else -> PathIconKind.NONE
}
@@ -12,6 +12,7 @@ import com.intellij.python.pytools.PyToolsState
import com.intellij.python.pytools.configuration.ExecutableDiscoveryMode
import com.intellij.python.pytools.statistics.PyToolUsagesCollector
import com.intellij.python.pytools.statistics.PyToolActionSource
import com.intellij.python.pytools.configuration.ConfigurablePyTool
import com.intellij.python.pytools.ui.PyToolsUiBundle
import com.intellij.python.pytools.ui.icons.PythonPytoolsUIIcons
import com.intellij.ui.AnimatedIcon
@@ -57,11 +58,12 @@ import javax.swing.table.TableCellRenderer
*/
internal class PyExternalToolsTable(
override val project: Project,
private val uv: UvController,
private val uv: PyToolManagementController,
) : TooltipHost {
/** Source-of-truth row list, materialised once from the [PyTool] extension point. */
private val rows: List<ToolRow> = PyTool.EP_NAME.extensionList
.filter { it is ConfigurablePyTool }
.sortedBy { it.presentableName.lowercase() }
.map { ToolRow(it, snapshotOf(it)) }
@@ -77,18 +79,18 @@ internal class PyExternalToolsTable(
override var hoveredRow: Int = -1
private set
/** View row currently under the mouse for the Path column; -1 means no hover. Drives the install-via-uv icon. */
/** View row currently under the mouse for the Path column; -1 means no hover. Drives the install/upgrade icon. */
override var pathHoveredRow: Int = -1
private set
override fun iconKindFor(toolRow: ToolRow?, pathFieldValue: PathFieldValue?): PathIconKind =
iconKindFor(toolRow, pathFieldValue, uv.uvAvailable.get(), uv::isUvManaged, uv::isUpgradeAvailable)
iconKindFor(toolRow, pathFieldValue, uv::isUpgradeAvailable)
override fun latestVersionFor(toolRow: ToolRow): String? = uv.latestVersionFor(toolRow)
/**
* Per-step availability for the Lookup column glyphs. SDK comes from the row's enumerated
* project SDKs, PATH from the row's resolved [PathFieldValue], uvx from the [UvController].
* project SDKs, PATH from the row's resolved [PathFieldValue], uvx from the [PyToolManagementController].
*/
override fun modeStatusFor(toolRow: ToolRow, mode: ExecutableDiscoveryMode): ChainStepStatus = when (mode) {
ExecutableDiscoveryMode.INTERPRETER -> toolRow.sdkAvailability.toChainStatus()
@@ -258,8 +260,8 @@ internal class PyExternalToolsTable(
// shouldn't trigger another install/upgrade when clicked.
if (rows[viewRow].lastSuccessMessage != null) return
when (pathIconAtHover(viewRow)) {
PathIconKind.INSTALL -> uv.installViaUv(rows[viewRow], PyToolActionSource.SETTINGS_TABLE)
PathIconKind.UPGRADE -> uv.upgradeViaUv(rows[viewRow], PyToolActionSource.SETTINGS_TABLE)
PathIconKind.INSTALL -> uv.installTool(rows[viewRow], PyToolActionSource.SETTINGS_TABLE)
PathIconKind.UPGRADE -> uv.upgradeTool(rows[viewRow], PyToolActionSource.SETTINGS_TABLE)
PathIconKind.RESET -> resetPathFor(rows[viewRow])
else -> Unit
}
@@ -215,7 +215,7 @@ private fun buildPathTooltip(
*/
private fun actionHintFor(kind: PathIconKind, latestVersion: String?): String? = when (kind) {
PathIconKind.NONE -> null
PathIconKind.INSTALL -> PyToolsUiBundle.message("settings.external.tools.install.via.uv.tooltip")
PathIconKind.INSTALL -> PyToolsUiBundle.message("settings.external.tools.install.tooltip")
PathIconKind.RESET -> PyToolsUiBundle.message("settings.external.tools.path.reset.tooltip")
PathIconKind.UPGRADE -> if (latestVersion != null) {
PyToolsUiBundle.message("settings.external.tools.path.upgrade.to.version.tooltip", latestVersion)
@@ -4,20 +4,21 @@ package com.intellij.python.pytools.ui.configuration
import com.intellij.openapi.observable.properties.AtomicProperty
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.Messages
import com.intellij.openapi.util.Version as PlatformVersion
import com.intellij.platform.eel.provider.getEelDescriptor
import com.intellij.platform.eel.provider.toEelApi
import com.intellij.platform.ide.progress.runWithModalProgressBlocking
import com.intellij.python.community.services.systemPython.SystemPythonService
import com.intellij.python.pytools.statistics.PyToolUsagesCollector
import com.intellij.python.pytools.statistics.PyToolActionSource
import com.intellij.python.pytools.Version
import com.intellij.python.pytools.ui.PyToolsUiBundle
import com.intellij.python.uv.backend.cli.uv.UvTool
import com.intellij.python.uv.backend.runtime.createUvToolRuntime
import com.intellij.python.uv.backend.runtime.uvCli
import com.intellij.python.uv.backend.UvPyTool
import com.intellij.python.pytools.PyToolManager
import com.intellij.python.pytools.PyToolManagerProvider
import com.intellij.python.pytools.performToolInstallation
import com.intellij.python.pytools.performToolUpgrade
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.sdk.installExecutableViaPythonScript
import com.jetbrains.python.sdk.uv.impl.getUvExecutableLocal
import com.jetbrains.python.packaging.PyPackageVersionComparator
import com.jetbrains.python.sdk.uv.impl.hasUvExecutableLocal
import com.jetbrains.python.sdk.uv.impl.setUvExecutableLocal
import kotlinx.coroutines.CancellationException
@@ -25,24 +26,23 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.nio.file.Path
/**
* Owns everything uv-related on the External Tools page: the in-memory uv state snapshot, the
* one-shot uv-availability detection, the uv-managed-tool list refresh, and the install / upgrade
* actions invoked from the Path column's hover icon (and the install-uv-itself action invoked
* from the footer hint).
* Backs the External Tools page's tool actions: the install / upgrade actions invoked from the Path
* column's hover icon (delegated to [PyToolManager] — uv when present, pip otherwise), the aggregated
* set of outdated tools that drives the upgrade affordance, uv-availability detection, and the
* install-uv-itself action invoked from the footer hint.
*
* The configurable hands the controller a [CoroutineScope] via [onShown] — typically the scope
* provided by `JComponent.launchOnShow`, so detection and background tasks live for the panel's
* showing-lifetime and are cancelled automatically when the page is hidden. Two callbacks bridge
* back to the configurable on EDT:
* - [onStateChanged] whenever uv state changes (availability / version / managed-tool set) so
* the configurable can refresh the table and rebuild the footer hint;
* - [onStateChanged] whenever state changes (uv availability / the outdated-tool set) so the
* configurable can refresh the table and rebuild the footer hint;
* - [refreshRow] forwarded into [ToolRow.probeVersion] after a successful install/upgrade so
* the freshly resolved version flows back into the row.
*/
internal class UvController(
internal class PyToolManagementController(
private val project: Project,
private val onStateChanged: () -> Unit,
private val refreshRow: (ToolRow) -> Unit,
@@ -66,104 +66,52 @@ internal class UvController(
*/
val uvAvailable: AtomicProperty<Boolean?> = AtomicProperty(null)
/** uv version reported by `uv self version --short`; null means not yet known. */
/**
* Package name → latest available version for tools that have a newer release, aggregated across all
* registered [PyToolManager]s (so it is populated even without uv). Drives the upgrade icon and its
* tooltip. Refreshed asynchronously; a tool present here is, by definition, manageable and outdated.
*/
@Volatile
var uvVersion: String? = null
var outdatedVersions: Map<String, String> = emptyMap()
private set
/**
* True iff the cached [uvVersion] is at least [UV_OUTDATED_SUPPORTED_SINCE], i.e. `uv tool list --outdated`
* is available. While [uvVersion] is `null` (detection in flight or failed), this stays `false` so we fall
* back to the legacy "try to upgrade" behaviour rather than miscalling a flag that isn't there.
*/
val supportsOutdated: Boolean
get() = uvVersion?.let { PlatformVersion.parseVersion(it) }?.let { it >= UV_OUTDATED_SUPPORTED_SINCE } == true
/**
* Names of tools currently installed by `uv tool install` (i.e. listed by `uv tool list`).
* Populated asynchronously after [uvAvailable] is confirmed; drives whether the Path column's
* hover icon is the plain version-info icon or the upgrade-via-uv button.
*/
@Volatile
var uvManagedNames: Set<String> = emptySet()
private set
/**
* Names of uv-managed tools that have a newer release available, per `uv tool list --outdated`.
* Maps the package name to the latest version uv would upgrade it to, so callers can surface
* "Update to X.Y.Z" without re-querying. Only populated when [supportsOutdated] is true; with
* older uv versions the legacy fallback uses [attemptedUpgrades] to decide whether the update
* icon should still be shown.
*/
@Volatile
var uvOutdatedVersions: Map<String, String> = emptyMap()
private set
/**
* Names of uv-managed tools whose upgrade was already attempted from this settings page in the
* current session. Used only when [supportsOutdated] is false: once we've kicked off `uv tool
* upgrade <name>` we can't know whether anything changed (uv's exit code is the same either way),
* so we hide the icon so the user isn't tempted to keep clicking the same "try" button.
*/
@Volatile
private var attemptedUpgrades: Set<String> = emptySet()
/**
* 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.
* Package name this row's tool is known by. Each tool maps 1:1 to a single PyPI package, so every
* install / upgrade / outdated lookup targets the tool's package 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 =
uvAvailable.get() == true && toolRow.uvPackageName() in uvManagedNames
/** Whether the upgrade icon should be offered for [toolRow] — i.e. a newer version is known. */
fun isUpgradeAvailable(toolRow: ToolRow): Boolean = toolRow.uvPackageName() in outdatedVersions
/**
* Whether the upgrade icon should be offered for [toolRow]. Two regimes:
* - modern uv (`supportsOutdated`) — only when the detected package is known-outdated;
* - legacy uv — always, unless the detected package has already been upgraded, after
* which the icon is suppressed (we have no way to confirm whether anything changed).
*/
fun isUpgradeAvailable(toolRow: ToolRow): Boolean {
val name = toolRow.uvPackageName()
return if (supportsOutdated) name in uvOutdatedVersions
else name !in attemptedUpgrades
}
/**
* Latest version uv would upgrade [toolRow]'s tool to, when known. Only returns non-null on
* modern uv where `--outdated` reports versions; the legacy path has no way to know in advance.
*/
fun latestVersionFor(toolRow: ToolRow): String? =
uvOutdatedVersions[toolRow.uvPackageName()]
/** Latest version [toolRow]'s tool can be upgraded to, when known. */
fun latestVersionFor(toolRow: ToolRow): String? = outdatedVersions[toolRow.uvPackageName()]
/**
* Called by the configurable from within `launchOnShow`'s coroutine. Stores [scope] for
* click-driven actions and kicks off the initial uv-availability detection.
* click-driven actions, detects uv availability (for the footer/uvx), and loads outdated tools.
*/
fun onShown(scope: CoroutineScope) {
this.scope = scope
scope.launch {
val available = hasUvExecutableLocal()
uvAvailable.set(available)
uvVersion = if (available) fetchUvVersion() else null
uvAvailable.set(hasUvExecutableLocal())
withContext(Dispatchers.Main) { onStateChanged() }
if (available) refreshUvManagedNames()
refreshOutdated()
}
}
/** Run `uv tool install <packageName>` with modal progress; refresh the row when it succeeds. */
fun installViaUv(toolRow: ToolRow, source: PyToolActionSource) = runUvToolAction(
/** Install the row's tool with modal progress via the shared installer path; refresh on success. */
fun installTool(toolRow: ToolRow, source: PyToolActionSource) = runToolAction(
toolRow = toolRow,
progressTitleKey = "settings.external.tools.install.via.uv.progress",
errorTitleKey = "settings.external.tools.install.via.uv.error.title",
action = { uvTool, name -> uvTool.install(name) },
progressTitleKey = "settings.external.tools.install.progress",
errorTitleKey = "settings.external.tools.install.error.title",
action = { toolRow.tool.performToolInstallation(project.getEelDescriptor().toEelApi()) },
onSuccess = {
PyToolUsagesCollector.Helper.logToolInstalled(project, toolRow.tool, source)
// Baseline copy — surfaced while the post-action `--version` probe is still running and
// we don't yet know the freshly installed version.
toolRow.lastSuccessMessage =
PyToolsUiBundle.message("settings.external.tools.install.via.uv.success.balloon", toolRow.uvPackageName())
PyToolsUiBundle.message("settings.external.tools.install.success.balloon", toolRow.uvPackageName())
},
onVersionResolved = { installedVersion ->
// [runUvToolAction] only fires this hook when the probed version is non-null, so we can
@@ -171,7 +119,7 @@ internal class UvController(
// [onSuccess] stays.
if (installedVersion != null) {
toolRow.lastSuccessMessage = PyToolsUiBundle.message(
"settings.external.tools.install.via.uv.success.to.version.balloon",
"settings.external.tools.install.success.to.version.balloon",
toolRow.uvPackageName(), installedVersion,
)
refreshRow(toolRow)
@@ -186,20 +134,17 @@ internal class UvController(
* pin and resolves to the latest compatible release, which matches what the user expects when
* they click the "Upgrade" icon next to an outdated tool.
*/
fun upgradeViaUv(toolRow: ToolRow, source: PyToolActionSource) {
fun upgradeTool(toolRow: ToolRow, source: PyToolActionSource) {
// Snapshot the pre-upgrade version so we can tell, after the post-action re-probe, whether
// the install actually changed anything (e.g. it could have already been latest).
val previousVersion = toolRow.version
runUvToolAction(
runToolAction(
toolRow = toolRow,
progressTitleKey = "settings.external.tools.upgrade.via.uv.progress",
errorTitleKey = "settings.external.tools.upgrade.via.uv.error.title",
action = { uvTool, name -> uvTool.install(name, reinstall = true) },
progressTitleKey = "settings.external.tools.upgrade.progress",
errorTitleKey = "settings.external.tools.upgrade.error.title",
action = { toolRow.tool.performToolUpgrade(project.getEelDescriptor().toEelApi()) },
onSuccess = {
PyToolUsagesCollector.Helper.logToolUpdated(project, toolRow.tool, source)
// Legacy fallback: remember the detected package was upgraded so the icon stops
// re-offering it. Tracked under the same name [isUpgradeAvailable] later reads.
attemptedUpgrades = attemptedUpgrades + toolRow.uvPackageName()
},
onVersionResolved = { newVersion ->
// Refine the baseline success message now that the post-action version is known
@@ -223,60 +168,50 @@ internal class UvController(
val name = toolRow.uvPackageName()
return when {
current != null && previous != null && previous == current ->
PyToolsUiBundle.message("settings.external.tools.upgrade.via.uv.up.to.date.balloon", name, current)
PyToolsUiBundle.message("settings.external.tools.upgrade.up.to.date.balloon", name, current)
current != null ->
PyToolsUiBundle.message("settings.external.tools.upgrade.via.uv.success.to.version.balloon", name, current)
PyToolsUiBundle.message("settings.external.tools.upgrade.success.to.version.balloon", name, current)
else ->
PyToolsUiBundle.message("settings.external.tools.upgrade.via.uv.success.balloon", name)
PyToolsUiBundle.message("settings.external.tools.upgrade.success.balloon", name)
}
}
/**
* Install `uv` itself by piggybacking on the same Python script used by the New Project / Setup
* SDK flow (`installExecutableViaPythonScript`). On success, persists the path with
* `setUvExecutableLocal` (matching the SDK setup flow), flips [uvAvailable] on, and fires
* Install `uv` itself through [UvPyTool]'s `performToolInstallation` (uv can't install itself, so
* this falls through to the pip-based installer into a system Python). On success, persists the path
* with `setUvExecutableLocal` (matching the SDK setup flow), flips [uvAvailable] on, and fires
* [onStateChanged] so the configurable can rebuild the footer hint and the table.
*/
fun installUv() {
val title = PyToolsUiBundle.message("settings.external.tools.install.uv.progress")
val errorTitle = PyToolsUiBundle.message("settings.external.tools.install.uv.error.title")
val result = runWithModalProgressBlocking(project, title) {
val systemPython = SystemPythonService()
.findSystemPythons().firstOrNull()
?: return@runWithModalProgressBlocking null
installExecutableViaPythonScript(systemPython.asExecutablePython.binary, "-n", "uv")
UvPyTool.getInstance().performToolInstallation(project.getEelDescriptor().toEelApi())
}
if (result == null) {
Messages.showErrorDialog(project, PyToolsUiBundle.message("settings.external.tools.install.uv.error.no.python"), errorTitle)
return
val installedPath = when (result) {
is Result.Success -> result.result
is Result.Failure -> {
Messages.showErrorDialog(project, result.error.toString(), errorTitle)
return
}
}
val failure = result as? Result.Failure<*>
if (failure != null) {
Messages.showErrorDialog(project, failure.error.toString(), errorTitle)
return
}
val installedPath = (result as? Result.Success<*>)?.result as? Path ?: return
setUvExecutableLocal(installedPath)
uvAvailable.set(true)
onStateChanged()
// Fetch the freshly installed uv's version + the list of uv-managed tools off the EDT.
scope?.launch {
uvVersion = fetchUvVersion()
withContext(Dispatchers.Main) { onStateChanged() }
refreshUvManagedNames()
}
// Now that uv exists, reload the outdated set so upgrade affordances light up.
scope?.launch { refreshOutdated() }
}
/**
* Shared driver for `uv tool install` / `uv tool upgrade`. Runs [action] under modal progress,
* surfaces errors via a message dialog, and on success invalidates the row's cached probe and
* refreshes the uv-managed list (so an Install can flip the row into "uv-managed" state).
* Shared driver for a tool install / upgrade. Runs [action] under modal progress, surfaces errors
* via a message dialog, and on success invalidates the row's cached probe and refreshes the
* outdated-tool set (so an Install can light up an upgrade affordance afterwards).
*/
private fun runUvToolAction(
private fun runToolAction(
toolRow: ToolRow,
progressTitleKey: String,
errorTitleKey: String,
action: suspend (UvTool, String) -> PyResult<String>,
action: suspend () -> PyResult<*>,
onSuccess: () -> Unit = {},
/**
* Fires once after the post-action `--version` re-probe publishes a version (or skips if
@@ -285,10 +220,9 @@ internal class UvController(
*/
onVersionResolved: (Version?) -> Unit = {},
) {
// Whatever the alias detection settled on (or the install-info fallback when nothing is
// detected) is what we pass to uv — keeps every step of this flow targeted at the same
// package: install/reinstall, the post-action `--version` re-probe, and the
// [attemptedUpgrades] / [uvOutdatedVersions] lookups in [isUpgradeAvailable].
// The package name the operation targets — used for the progress/error titles and, later, the
// post-action `--version` re-probe. Showing e.g. "basedpyright" (what uv actually installs) keeps
// the dialog consistent with `uv tool list` even when the row's label differs ("Pyright").
val packageName = toolRow.uvPackageName()
// The progress and error titles surface the same `packageName`: showing "Installing
// basedpyright" instead of the tool's presentable name keeps the dialog consistent with
@@ -303,22 +237,13 @@ internal class UvController(
toolRow.actionInProgress = true
refreshRow(toolRow)
val result = try {
runWithModalProgressBlocking(project, title) {
val uvPath = getUvExecutableLocal() ?: return@runWithModalProgressBlocking null
action(createUvToolRuntime(uvPath).uvCli().tool(), packageName)
}
runWithModalProgressBlocking(project, title) { action() }
}
catch (e: CancellationException) {
toolRow.actionInProgress = false
refreshRow(toolRow)
throw e
}
if (result == null) {
toolRow.actionInProgress = false
refreshRow(toolRow)
Messages.showErrorDialog(project, PyToolsUiBundle.message("settings.external.tools.install.via.uv.error.no.uv"), errorTitle)
return
}
val failure = result as? Result.Failure<*>
if (failure != null) {
toolRow.actionInProgress = false
@@ -355,39 +280,21 @@ internal class UvController(
}
refreshRow(updatedRow)
}
activeScope.launch { refreshUvManagedNames() }
}
/** Best-effort `uv self version --short` lookup; null on any error. */
private suspend fun fetchUvVersion(): String? {
val uvPath = getUvExecutableLocal() ?: return null
val result = createUvToolRuntime(uvPath).uvCli().self().version(short = true)
val raw = (result as? Result.Success<*>)?.result as? String ?: return null
return raw.trim().takeIf { it.isNotEmpty() }
activeScope.launch { refreshOutdated() }
}
/**
* Reload the cached set of tools installed by `uv tool install` (used by the Path-cell hover
* icon to decide between the plain version-info icon and the upgrade-via-uv button). Runs
* fully on background coroutines; the EDT is only touched via [onStateChanged].
* Reload the set of outdated tools, aggregated across every registered [PyToolManager] (so it works
* without uv). Runs fully on background coroutines; the EDT is only touched via [onStateChanged].
*/
private suspend fun refreshUvManagedNames() {
val uvPath = getUvExecutableLocal() ?: return
val tool = createUvToolRuntime(uvPath).uvCli().tool()
val installed = tool.listInstalled().getOr { return }
val names = installed.map { it.name }.toSet()
val outdated = if (supportsOutdated) {
tool.listOutdated().getOr { return }.associate { it.name to it.latestVersion }
}
else emptyMap()
if (names == uvManagedNames && outdated == uvOutdatedVersions) return
uvManagedNames = names
uvOutdatedVersions = outdated
private suspend fun refreshOutdated() {
val eel = project.getEelDescriptor().toEelApi()
val outdated = PyToolManagerProvider.managerFor(eel)?.list().orEmpty()
.filterValues { PyPackageVersionComparator.STR_COMPARATOR.compare(it.latestVersion, it.installedVersion) > 0 }
.map { (tool, info) -> tool.packageName.name to info.latestVersion }
.toMap()
if (outdated == outdatedVersions) return
outdatedVersions = outdated
withContext(Dispatchers.Main) { onStateChanged() }
}
companion object {
/** First uv release that exposes `--outdated` on `uv tool list` (released 2026-03-13). */
private val UV_OUTDATED_SUPPORTED_SINCE: PlatformVersion = PlatformVersion(0, 10, 10)
}
}
@@ -9,6 +9,9 @@
<extensionPoint qualifiedName="com.intellij.python.pytools.pyTool"
interface="com.intellij.python.pytools.PyTool"
dynamic="true"/>
<extensionPoint qualifiedName="com.intellij.python.pytools.pyToolManagerProvider"
interface="com.intellij.python.pytools.PyToolManagerProvider"
dynamic="true"/>
</extensionPoints>
<extensions defaultExtensionNs="com.intellij">
@@ -4,3 +4,4 @@ python.tool.cli.error.response.out.of.pattern=Tool command responded with non-ex
selected.tool.is.wrong=The selected tool is not {0}, it is {1}
uvx.is.not.installed=uvx is not installed
uvx.is.not.supported=uvx is not supported
python.tool.install.no.installer=Cannot install {0}: no installer is available
@@ -7,12 +7,16 @@ import com.intellij.openapi.util.NlsSafe
import com.intellij.python.pytools.statistics.PyToolFusSnapshot
import com.jetbrains.python.packaging.PyPackageName
import org.jetbrains.annotations.Nls
import javax.swing.Icon
import com.intellij.openapi.util.Version as PlatformVersion
interface PyTool {
val presentableName: @NlsSafe String
val packageName: PyPackageName
/** Icon representing the tool (e.g. status-bar widget, advertiser notification, External Tools table). */
val icon: Icon
/**
* One-line user-facing description of the tool (e.g. "Linter and code formatter for Python").
* Surfaced in the External Tools settings tooltip. Required — every tool must provide a
@@ -75,5 +79,10 @@ interface PyTool {
companion object {
val EP_NAME: ExtensionPointName<PyTool> = ExtensionPointName.create("com.intellij.python.pytools.pyTool")
fun findByPackageName(packageName: String): PyTool? {
val normalized = PyPackageName.from(packageName).name
return EP_NAME.extensionList.firstOrNull { it.packageName.name == normalized }
}
}
}
@@ -2,6 +2,7 @@ package com.intellij.python.pytools
import com.intellij.execution.configurations.PathEnvironmentVariableUtil
import com.intellij.openapi.project.Project
import com.intellij.platform.eel.EelApi
import com.intellij.platform.eel.EelOsFamily
import com.intellij.platform.eel.provider.LocalEelDescriptor
import com.intellij.platform.eel.provider.asNioPath
@@ -136,4 +137,35 @@ private fun PyTool.findExecutableInPath(state: PyToolsState.ToolEntry, executabl
fun PyTool.findExecutableInPath(
executableName: String = packageName.name,
osFamily: EelOsFamily = LocalEelDescriptor.osFamily,
): Path? = resolveExecutableOnPath(executableName, osFamily)
/**
* Looks up [executableName] on the system PATH by its OS-specific binary name. This is how the
* External Tools settings page resolves tool executables (via [findExecutableInPath]); shared so
* other callers can resolve an installed executable the same way.
*/
fun resolveExecutableOnPath(
executableName: String,
osFamily: EelOsFamily = LocalEelDescriptor.osFamily,
): Path? = PathEnvironmentVariableUtil.findInPath(osFamily.getOsSpecificBinaryName(executableName))?.toPath()
/**
* Installs this tool's executable into the environment described by [eel], using the first available
* [PyToolManager] (`uv tool install` when uv is present, otherwise a pip install via a system Python).
* Returns the resolved executable path on success.
*/
suspend fun PyTool.performToolInstallation(eel: EelApi): PyResult<Path> {
val manager = PyToolManagerProvider.managerFor(eel)
?: return PyResult.localizedError(message("python.tool.install.no.installer", presentableName))
return manager.install(this)
}
/**
* Upgrades this tool to the latest version in the environment described by [eel], using the first
* available [PyToolManager]. Returns the resolved executable path on success.
*/
suspend fun PyTool.performToolUpgrade(eel: EelApi): PyResult<Path> {
val manager = PyToolManagerProvider.managerFor(eel)
?: return PyResult.localizedError(message("python.tool.install.no.installer", presentableName))
return manager.upgrade(this)
}
@@ -0,0 +1,38 @@
// 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
import com.jetbrains.python.errorProcessing.PyResult
import java.nio.file.Path
/**
* Installs and upgrades [PyTool] executables in a single environment. An instance is bound to that
* environment (a project's EEL) by the [PyToolManagerProvider] that created it, so [install] / [upgrade]
* take no `eel` argument.
*
* Backends (uv, pip, …) provide managers via [PyToolManagerProvider]; callers obtain one with
* [PyToolManagerProvider.managerFor] and use it for every operation.
*/
interface PyToolManager {
/** Installs [tool]; returns the resolved executable path. */
suspend fun install(tool: PyTool): PyResult<Path>
/** Upgrades [tool] to the latest version. */
suspend fun upgrade(tool: PyTool): PyResult<Path>
/**
* All managed tools installed in this manager's environment, keyed by [PyTool], with their installed
* and latest available version (the latest resolved from PyPI). Empty when no managed tool is installed.
*/
suspend fun list(): Map<PyTool, InstalledInfo>
}
/**
* Version and location of an installed managed tool: its resolved executable [path], the currently
* [installedVersion], and the [latestVersion] available from the configured repositories (equal to
* [installedVersion] when the tool is already up to date).
*/
data class InstalledInfo(
val path: Path,
val installedVersion: String,
val latestVersion: String,
)
@@ -0,0 +1,29 @@
// 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
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.platform.eel.EelApi
/**
* Creates a [PyToolManager] for a given environment, contributed per backend (e.g. uv, pip) through
* [EP_NAME]. Providers are consulted in registration order; the first one able to operate in the
* target environment wins.
*
* Implementations live in higher-level modules (uv backend, the main python impl) so `python-pytools`
* does not need to depend on them.
*/
interface PyToolManagerProvider {
/**
* A [PyToolManager] bound to [eel], or `null` when this provider cannot operate there — e.g. its
* backing tool (uv on PATH, a system Python, …) is not present.
*/
suspend fun forEel(eel: EelApi): PyToolManager?
companion object {
val EP_NAME: ExtensionPointName<PyToolManagerProvider> =
ExtensionPointName.create("com.intellij.python.pytools.pyToolManagerProvider")
/** The [PyToolManager] from the highest-priority provider that can operate in [eel], or `null`. */
suspend fun managerFor(eel: EelApi): PyToolManager? = EP_NAME.extensionList.firstNotNullOfOrNull { it.forEel(eel) }
}
}
@@ -0,0 +1,15 @@
// 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.configuration
import com.intellij.openapi.options.UnnamedConfigurable
import com.intellij.openapi.project.Project
/**
* Implemented by a [com.intellij.python.pytools.PyTool] that has settings on the External Tools page:
* it contributes the detail panel shown in the table's Edit dialog. Presence of this interface is what
* marks a tool as configurable (and therefore visible) on that page. Kept separate from `PyTool` so a
* tool opts into a settings UI without every tool having to.
*/
interface ConfigurablePyTool {
fun createConfigurable(project: Project): UnnamedConfigurable
}
@@ -5,7 +5,6 @@ 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
@@ -19,9 +18,6 @@ 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 {
@@ -5,6 +5,7 @@ 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.python.pytools.icons.PythonPyToolsIcons
import com.intellij.configurationStore.serialize
import com.jetbrains.python.packaging.PyPackageName
import com.intellij.util.xmlb.XmlSerializer
@@ -77,6 +78,7 @@ internal class PyToolsStateSerializationTest {
override val presentableName: String = "ruff"
override val packageName: PyPackageName = PyPackageName.from("ruff")
override val description: String = "ruff"
override val icon = PythonPyToolsIcons.Logo
}
// All tools at their defaults -> nothing to persist -> no .idea/pyLspTools.xml.
+2
View File
@@ -31,6 +31,7 @@ jvm_library(
"//python/python-pytools:pytools",
"//python/python-exec-service:community-execService",
"//platform/eel-provider",
"//python/python-sdk:sdk",
],
)
@@ -55,6 +56,7 @@ jvm_library(
"//python/python-pytools:pytools_test_lib",
"//python/python-exec-service:community-execService_test_lib",
"//platform/eel-provider:eel-provider_test_lib",
"//python/python-sdk:sdk_test_lib",
],
)
### auto-generated section `build intellij.python.uv.backend` end
@@ -19,5 +19,6 @@
<orderEntry type="module" module-name="intellij.python.pytools" />
<orderEntry type="module" module-name="intellij.python.community.execService" />
<orderEntry type="module" module-name="intellij.platform.eel.provider" />
<orderEntry type="module" module-name="intellij.python.sdk" />
</component>
</module>
@@ -15,4 +15,8 @@
<extensions defaultExtensionNs="com.intellij.python.pyproject.model">
<pyprojectmanager implementation="com.intellij.python.uv.backend.UvPyProjectManager"/>
</extensions>
<extensions defaultExtensionNs="com.intellij">
<python.pytools.pyTool implementation="com.intellij.python.uv.backend.UvPyTool"/>
<python.pytools.pyToolManagerProvider implementation="com.intellij.python.uv.backend.UvToolManagerProvider" order="first"/>
</extensions>
</idea-plugin>
@@ -0,0 +1 @@
python.uv.tool.description=An extremely fast Python package and project manager, written in Rust.
@@ -0,0 +1,16 @@
// 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.uv.backend
import com.intellij.DynamicBundle
import org.jetbrains.annotations.Nls
import org.jetbrains.annotations.PropertyKey
internal object PyUvBundle {
private const val BUNDLE = "messages.PyUvBundle"
private val INSTANCE = DynamicBundle(PyUvBundle::class.java, BUNDLE)
fun message(key: @PropertyKey(resourceBundle = BUNDLE) String, vararg params: Any): @Nls String {
return INSTANCE.getMessage(key, *params)
}
}
@@ -0,0 +1,29 @@
// 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.uv.backend
import com.intellij.python.pytools.PyTool
import com.intellij.python.uv.backend.PyUvBundle.message
import com.intellij.python.uv.common.icons.PythonUvCommonIcons
import com.jetbrains.python.packaging.PyPackageName
import org.jetbrains.annotations.ApiStatus
import javax.swing.Icon
/**
* [uv](https://docs.astral.sh/uv/) — an extremely fast Python package and project manager written in
* Rust by Astral. It resolves and installs dependencies, creates and manages virtual environments,
* installs and switches Python versions, builds and publishes packages, and installs standalone
* command-line tools into isolated environments (`uv tool install`). It aims to replace pip, pipx,
* pip-tools, virtualenv, and pyenv with a single fast tool.
*/
@ApiStatus.Internal
class UvPyTool : PyTool {
override val presentableName: String = "uv"
override val packageName: PyPackageName = PyPackageName.from("uv")
override val description: String get() = message("python.uv.tool.description")
override val icon: Icon get() = PythonUvCommonIcons.UV
@Suppress("CompanionObjectInExtension")
companion object {
fun getInstance(): UvPyTool = PyTool.EP_NAME.findExtensionOrFail(UvPyTool::class.java)
}
}
@@ -0,0 +1,20 @@
// 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.uv.backend
import com.jetbrains.python.sdk.ToolCommandExecutor
import org.jetbrains.annotations.ApiStatus
/**
* Locates the `uv` executable for [UvToolManagerProvider].
*
* 262 backport note: on master this lives alongside `setUvExecutableLocal` in this package; on 262 the
* writer `setUvExecutableLocal` stays in `com.jetbrains.python.sdk.uv.impl`. Both share the
* "PyCharm.Uv.Path" setting, so detection here stays consistent with the SDK setup flow.
*/
@ApiStatus.Internal
val UV_TOOL: ToolCommandExecutor = ToolCommandExecutor(
"uv",
getToolPathFromSettings = { getValue(UV_PATH_SETTING) },
)
private const val UV_PATH_SETTING: String = "PyCharm.Uv.Path"
@@ -0,0 +1,76 @@
// 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.uv.backend
import com.intellij.platform.eel.EelApi
import com.intellij.python.pytools.InstalledInfo
import com.intellij.python.pytools.PyTool
import com.intellij.python.pytools.PyToolManager
import com.intellij.python.pytools.PyToolManagerProvider
import com.intellij.python.uv.backend.runtime.createUvToolRuntime
import com.intellij.python.uv.backend.runtime.uvCli
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.getOrNull
import com.jetbrains.python.sdk.add.v2.FileSystem
import com.jetbrains.python.sdk.add.v2.PathHolder
import com.jetbrains.python.sdk.add.v2.toFileSystem
import com.jetbrains.python.sdk.impl.PySdkBundle
import org.jetbrains.annotations.ApiStatus
import java.nio.file.Path
/**
* Provides a [UvToolManager] when a local `uv` is available for the target environment. Registered
* `order="first"` so uv is preferred over the pip fallback.
*/
@ApiStatus.Internal
class UvToolManagerProvider : PyToolManagerProvider {
override suspend fun forEel(eel: EelApi): PyToolManager? {
val fileSystem = eel.toFileSystem()
val uv = UV_TOOL.getToolExecutable(fileSystem, null)?.path ?: return null
return UvToolManager(fileSystem, uv)
}
}
/**
* `uv tool` manager bound to a resolved `uv` executable and its filesystem. Install/upgrade use
* `uv tool install [--reinstall]` (isolated, always-latest env); `--reinstall` drops any prior pin so
* an upgrade resolves to the latest release rather than staying on the originally-installed spec.
*/
private class UvToolManager(
private val fileSystem: FileSystem<PathHolder.Eel>,
private val uv: Path,
) : PyToolManager {
override suspend fun install(tool: PyTool): PyResult<Path> = run(tool, reinstall = false)
override suspend fun upgrade(tool: PyTool): PyResult<Path> = run(tool, reinstall = true)
/**
* All uv-installed tools, from `uv tool list --show-paths`, with latest versions overlaid from
* `uv tool list --outdated` (uv 0.10.10+; on older uv that call fails and every tool is reported as
* up to date). Tools uv installed that the IDE does not know as a [PyTool], or whose executable path
* is missing, are skipped.
*/
override suspend fun list(): Map<PyTool, InstalledInfo> {
val tool = createUvToolRuntime(uv).uvCli().tool()
val installed = tool.list(showPaths = true).getOr { return emptyMap() }
val latestByName = tool.list(outdated = true).getOrNull().orEmpty()
.mapNotNull { outdated -> outdated.latestVersion?.let { outdated.name to it } }
.toMap()
return installed.mapNotNull { uvTool ->
val pyTool = PyTool.findByPackageName(uvTool.name) ?: return@mapNotNull null
// A tool may expose several entry points (e.g. pyright, pyright-langserver, …); prefer the one
// named after the tool, otherwise take the first uv reported.
val executablePath = uvTool.executables[uvTool.name] ?: uvTool.executables.values.firstOrNull() ?: return@mapNotNull null
// `uv tool list --outdated` omits up-to-date tools, so absence means latest == installed.
val latestVersion = latestByName[uvTool.name] ?: uvTool.version
pyTool to InstalledInfo(path = executablePath, installedVersion = uvTool.version, latestVersion = latestVersion)
}.toMap()
}
private suspend fun run(tool: PyTool, reinstall: Boolean): PyResult<Path> {
createUvToolRuntime(uv).uvCli().tool().install(tool.packageName.name, reinstall = reinstall).getOr { return it }
val executable = fileSystem.detectTool(tool.packageName.name)
?: return PyResult.localizedError(PySdkBundle.message("cannot.find.executable", tool.packageName.name, fileSystem.userReadableName))
return Result.success(executable.path)
}
}
@@ -25,7 +25,7 @@ class UvTool(runtime: PyToolRuntime) : UvCommand("tool", runtime) {
* Useful for breaking out of a previously pinned install (uv leaves the existing entry alone
* otherwise, and `uv tool upgrade` is bounded by the original constraints so it cannot help).
*/
suspend fun install(name: String, reinstall: Boolean? = null): PyResult<String> {
suspend fun install(name: String, reinstall: Boolean = false): PyResult<String> {
val options = listOf(reinstall to "--reinstall").makeOptions()
return executeAndHandleErrors("install", name, *options, transformer = ZeroCodeStdoutTransformer)
}
@@ -40,43 +40,55 @@ class UvTool(runtime: PyToolRuntime) : UvCommand("tool", runtime) {
}
/**
* List installed tools
* `uv tool list`, parsed into one [UvToolListResult] per installed tool. Optional fields are filled
* according to the flags: [outdated] populates [UvToolListResult.latestVersion] for tools with a newer
* release (uv 0.10.10+ lists only those), and [showPaths] populates [UvToolListResult.envPath] plus the
* [UvToolListResult.executables] map (entry-point name -> path).
*
* uv prints a header line `name vVERSION [latest: NEWER]? (env/path)?` per tool, optionally followed
* (with `--show-paths`) by one `- entrypoint (exe/path)` line per entry point (a tool may expose
* several). Paths may contain spaces, so the parenthesized tails are matched greedily.
*/
suspend fun list(showVersionSpecifiers: Boolean? = null, showPaths: Boolean? = null, outdated: Boolean? = null): PyResult<String> {
suspend fun list(showVersionSpecifiers: Boolean = false, showPaths: Boolean = false, outdated: Boolean = false): PyResult<List<UvToolListResult>> {
val options = listOf(
showVersionSpecifiers to "--show-version-specifiers",
showPaths to "--show-paths",
outdated to "--outdated",
).makeOptions()
return executeAndHandleErrors("list", *options, transformer = ZeroCodeStdoutTransformer)
}
val stdout = executeAndHandleErrors("list", *options, transformer = ZeroCodeStdoutTransformer).getOr { return it }
/**
* Parsed form of `uv tool list --show-paths`. Each header line `name vX.Y.Z (path/to/env)` becomes one entry;
* the `- executable` lines under each header are ignored (they are re-derivable from the env directory).
*/
suspend fun listInstalled(): PyResult<List<UvInstalledTool>> {
val stdout = list(showPaths = true).getOr { return it }
val headerRegex = Regex("""^(\S+) v(\S+) \((.+)\)$""")
val tools = stdout.lineSequence()
.mapNotNull { headerRegex.matchEntire(it.trim()) }
.map { UvInstalledTool(name = it.groupValues[1], version = it.groupValues[2], envPath = Path.of(it.groupValues[3])) }
.toList()
return Result.success(tools)
}
val headerRegex = Regex("""^(\S+) v(\S+)(?: \[latest:\s*(\S+)])?(?: \((.+)\))?$""")
val entryPointRegex = Regex("""^-\s+(\S+)\s+\((.+)\)$""")
/**
* Parsed form of `uv tool list --outdated` (available since uv 0.10.10). Each header line
* `name vCURRENT [latest: NEWER]` becomes one entry; subordinate `- executable` lines are ignored.
* Returns only tools that have a newer release available.
*/
suspend fun listOutdated(): PyResult<List<UvOutdatedTool>> {
val stdout = list(outdated = true).getOr { return it }
val headerRegex = Regex("""^(\S+) v(\S+) \[latest:\s*(\S+)]$""")
val tools = stdout.lineSequence()
.mapNotNull { headerRegex.matchEntire(it.trim()) }
.map { UvOutdatedTool(name = it.groupValues[1], currentVersion = it.groupValues[2], latestVersion = it.groupValues[3]) }
.toList()
val tools = mutableListOf<UvToolListResult>()
var pending: UvToolListResult? = null
val executables = linkedMapOf<String, Path>()
fun flushPending() {
val tool = pending ?: return
tools += tool.copy(executables = executables.toMap())
executables.clear()
pending = null
}
for (rawLine in stdout.lineSequence()) {
val line = rawLine.trim()
val header = headerRegex.matchEntire(line)
if (header != null) {
flushPending()
pending = UvToolListResult(
name = header.groupValues[1],
version = header.groupValues[2],
latestVersion = header.groupValues[3].ifEmpty { null },
envPath = header.groupValues[4].ifEmpty { null }?.let(Path::of),
)
continue
}
if (pending == null) continue
val entryPoint = entryPointRegex.matchEntire(line) ?: continue
executables[entryPoint.groupValues[1]] = Path.of(entryPoint.groupValues[2])
}
flushPending()
return Result.success(tools)
}
@@ -91,22 +103,25 @@ class UvTool(runtime: PyToolRuntime) : UvCommand("tool", runtime) {
suspend fun updateShell(): PyResult<Unit> = TODO()
/**
* Show the path to the uv tools directory
* `uv tool dir` — the directory uv stores tools in, or (with [bin] = true) the directory their
* executables are placed on `PATH`. uv prints a single path line, parsed here into a [Path].
*/
suspend fun dir(bin: Boolean? = null): PyResult<String> {
suspend fun dir(bin: Boolean = false): PyResult<Path> {
val options = listOf(bin to "--bin").makeOptions()
return executeAndHandleErrors("dir", *options, transformer = ZeroCodeStdoutTransformer)
}
/**
* Convenience wrapper over [dir] with `--bin` that returns a parsed [Path].
*/
suspend fun binDir(): PyResult<Path> {
val output = dir(bin = true).getOr { return it }
val output = executeAndHandleErrors("dir", *options, transformer = ZeroCodeStdoutTransformer).getOr { return it }
return Result.success(Path.of(output.trim()))
}
}
data class UvInstalledTool(val name: String, val version: String, val envPath: Path)
data class UvOutdatedTool(val name: String, val currentVersion: String, val latestVersion: String)
/**
* One entry of `uv tool list`. [name] and installed [version] are always present; [latestVersion] is
* set only for outdated tools (`--outdated`), and [envPath] plus [executables] (each entry point's name
* mapped to its path, insertion-ordered as uv printed them) only with `--show-paths`.
*/
data class UvToolListResult(
val name: String,
val version: String,
val latestVersion: String? = null,
val envPath: Path? = null,
val executables: Map<String, Path> = emptyMap(),
)
@@ -113,7 +113,7 @@ class UvCliTest {
@Test
fun testTool(): Unit = timeoutRunBlocking(60.seconds) {
val tool = myRuntime.uvCli().tool()
assertTrue(tool.dir().getOrThrow().isNotBlank())
assertTrue(tool.dir().getOrThrow().isAbsolute)
tool.list().getOrThrow()
}
@@ -141,21 +141,21 @@ class UvCliTest {
"expected ${pkg.name} install under the class-scoped UV_TOOL_DIR ${uvContext.uvToolDirPath}, missing: $expectedToolEnv"
}
// 2. listInstalled() should surface the freshly installed tool at the pinned version.
val installed = tool.listInstalled().getOrThrow()
// 2. list(showPaths) should surface the freshly installed tool at the pinned version.
val installed = tool.list(showPaths = true).getOrThrow()
val installedEntry = installed.firstOrNull { it.name == pkg.name }
assertNotNull(installedEntry) { "expected ${pkg.name} in listInstalled(), got $installed" }
assertNotNull(installedEntry) { "expected ${pkg.name} in list(), got $installed" }
assertEquals(pkg.version, installedEntry!!.version) {
"expected ${pkg.spec()} right after install, got ${installedEntry.version}"
}
// 3. listOutdated() should report it with a newer latestVersion (we pinned to an older release).
val outdatedBefore = tool.listOutdated().getOrThrow()
// 3. list(outdated) should report it with a newer latestVersion (we pinned to an older release).
val outdatedBefore = tool.list(outdated = true, showPaths = true).getOrThrow()
val outdatedEntry = outdatedBefore.firstOrNull { it.name == pkg.name }
assertNotNull(outdatedEntry) {
"expected ${pkg.name} in listOutdated() before upgrade, got $outdatedBefore"
"expected ${pkg.name} in list(outdated = true) before upgrade, got $outdatedBefore"
}
assertEquals(pkg.version, outdatedEntry!!.currentVersion)
assertEquals(pkg.version, outdatedEntry!!.version)
assertNotEquals(pkg.version, outdatedEntry.latestVersion) {
"latestVersion must differ from the pinned ${pkg.version} for the outdated signal to mean anything"
}
@@ -166,7 +166,7 @@ class UvCliTest {
// This is exactly the production path that surfaces "{tool} is already up to date" in
// the External Tools settings balloon.
tool.upgrade(pkg.name).getOrThrow()
val outdatedAfterUpgrade = tool.listOutdated().getOrThrow()
val outdatedAfterUpgrade = tool.list(outdated = true, showPaths = true).getOrThrow()
assertTrue(outdatedAfterUpgrade.any { it.name == pkg.name }) {
"uv tool upgrade respects the original pin; ${pkg.name} should still be outdated, got $outdatedAfterUpgrade"
}
@@ -174,9 +174,9 @@ class UvCliTest {
// 5. `install(name, reinstall = true)` (uv's `--reinstall`) drops the prior pin and
// installs the latest release. After that the outdated list must no longer mention it.
tool.install(pkg.name, reinstall = true).getOrThrow()
val outdatedAfterReinstall = tool.listOutdated().getOrThrow()
val outdatedAfterReinstall = tool.list(outdated = true, showPaths = true).getOrThrow()
assertTrue(outdatedAfterReinstall.none { it.name == pkg.name }) {
"after install(reinstall=true) ${pkg.name} should drop off listOutdated(), got $outdatedAfterReinstall"
"after install(reinstall=true) ${pkg.name} should drop off list(outdated = true), got $outdatedAfterReinstall"
}
}
}
@@ -23,18 +23,23 @@ import com.intellij.openapi.ui.popup.JBPopup
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.util.use
import com.intellij.openapi.vfs.newvfs.RefreshQueue
import com.intellij.platform.eel.provider.getEelDescriptor
import com.intellij.platform.eel.provider.toEelApi
import com.intellij.platform.ide.progress.withBackgroundProgress
import com.intellij.psi.PsiFile
import com.intellij.python.community.common.tools.ToolId
import com.intellij.python.pyproject.model.api.ModuleCreateInfo
import com.intellij.python.pyproject.model.api.autoConfigureSdkIfNeeded
import com.intellij.python.pyproject.model.api.getModuleInfo
import com.intellij.python.pyproject.statistics.PyProjectTomlCollector
import com.intellij.python.pytools.PyTool
import com.intellij.python.pytools.performToolInstallation
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.DropDownLink
import com.intellij.util.PlatformUtils
import com.jetbrains.python.PyBundle
import com.jetbrains.python.configuration.PyActiveSdkModuleConfigurable
import com.jetbrains.python.errorProcessing.ErrorSink
import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.inspections.InspectionRunnerResult
import com.jetbrains.python.orLogException
import com.jetbrains.python.sdk.ModuleOrProject
@@ -190,14 +195,19 @@ private class UseProvidedInterpreterFix(private val myCreateSdkInfo: CreateSdkIn
private class SuggestToolInstallationFix(
private val myModule: Module,
private val myCreateSdkInfo: CreateSdkInfo.WillInstallTool,
private val myTool: ToolId,
) : InterpreterFix {
override fun createActionLink(module: Module, project: Project, psiFile: PsiFile, executor: BusyGuardExecutor): ActionLink {
return ActionLink(myCreateSdkInfo.intentionName) {
val pyTool = PyTool.findByPackageName(myCreateSdkInfo.toolToInstall) ?: return@ActionLink
executor.execute {
val lifetime = PyProjectSdkConfiguration.suppressTipAndInspectionsFor(myModule, myTool.id)
val lifetime = PyProjectSdkConfiguration.suppressTipAndInspectionsFor(myModule, myCreateSdkInfo.toolToInstall)
withBackgroundProgress(project, myCreateSdkInfo.intentionName, false) {
lifetime.use { PyProjectSdkConfiguration.installToolAndShowErrorIfNeeded(myModule, myCreateSdkInfo.pathPersister, myCreateSdkInfo.toolToInstall) }
lifetime.use {
val eel = project.getEelDescriptor().toEelApi()
pyTool.performToolInstallation(eel).mapSuccess(myCreateSdkInfo.pathPersister).errorOrNull?.also {
ErrorSink().emit(it, project)
}
}
}
}
}
@@ -219,7 +229,7 @@ private suspend fun Module.getQuickFixBySdkSuggestion(i: ModuleCreateInfo?): Fin
}
is CreateSdkInfo.WillInstallTool -> {
logger.trace { "$this: Tool installation will be suggested to the user" }
FindQuickFixResult.ShowUserFix(SuggestToolInstallationFix(this, createSdkInfo, i.toolId))
FindQuickFixResult.ShowUserFix(SuggestToolInstallationFix(this, createSdkInfo))
}
}
}
@@ -12,6 +12,7 @@ import com.intellij.openapi.vfs.findPsiFile
import com.intellij.platform.ide.progress.withBackgroundProgress
import com.intellij.platform.util.progress.reportRawProgress
import com.intellij.python.community.common.tools.ToolId
import com.intellij.python.community.impl.poetry.backend.PoetryPyTool
import com.intellij.python.community.impl.poetry.common.POETRY_TOOL_ID
import com.intellij.python.community.impl.poetry.common.poetryPath
import com.intellij.python.community.services.systemPython.SystemPythonService
@@ -100,11 +101,11 @@ internal class PyPoetrySdkConfiguration : PyProjectTomlConfigurationExtension {
*/
else if (poetryLockExists || (isPoetryProject && checkToml)) {
val pathPersister: (Path) -> Unit = { path -> PropertiesComponent.getInstance().poetryPath = path.toString() }
val toolName = "poetry"
val tool = PoetryPyTool.getInstance()
EnvCheckerResult.SuggestToolInstallation(
toolToInstall = toolName,
toolToInstall = tool.packageName.name,
pathPersister = pathPersister,
intentionName = PyBundle.message("sdk.create.custom.venv.install.fix.title.using.pip", "poetry")
intentionName = PyBundle.message("sdk.create.custom.venv.install.fix.title", tool.presentableName)
)
}
else EnvCheckerResult.CannotConfigure
@@ -3,9 +3,14 @@ package com.jetbrains.python.sdk.add.v2
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.ui.validation.DialogValidationRequestor
import com.intellij.platform.eel.provider.getEelDescriptor
import com.intellij.platform.eel.provider.localEel
import com.intellij.platform.eel.provider.toEelApi
import com.intellij.platform.ide.progress.ModalTaskOwner
import com.intellij.platform.ide.progress.runWithModalProgressBlocking
import com.intellij.python.pytools.PyTool
import com.intellij.python.pytools.Version
import com.intellij.python.pytools.performToolInstallation
import com.intellij.ui.components.ActionLink
import com.intellij.ui.dsl.builder.Panel
import com.intellij.util.concurrency.annotations.RequiresEdt
@@ -18,7 +23,6 @@ import com.jetbrains.python.newProject.collector.InterpreterStatisticsInfo
import com.jetbrains.python.sdk.ModuleOrProject
import com.jetbrains.python.sdk.baseDir
import com.jetbrains.python.sdk.flavors.PythonSdkFlavor
import com.jetbrains.python.sdk.installExecutableViaPythonScript
import com.jetbrains.python.sdk.setAssociationToModule
import com.jetbrains.python.statistics.InterpreterCreationMode
import com.jetbrains.python.statistics.InterpreterType
@@ -27,9 +31,7 @@ import kotlinx.coroutines.flow.first
import org.jetbrains.annotations.ApiStatus.Internal
import java.nio.file.Path
@Internal
internal abstract class CustomNewEnvironmentCreator<P : PathHolder>(
private val name: String,
model: PythonMutableTargetAddInterpreterModel<P>,
protected val errorSink: ErrorSink,
) : PythonNewEnvironmentCreator<P>(model) {
@@ -50,8 +52,8 @@ internal abstract class CustomNewEnvironmentCreator<P : PathHolder>(
fileSystem = model.fileSystem,
pathValidator = toolValidator,
validationRequestor = validationRequestor,
labelText = message("sdk.create.custom.venv.executable.path", name),
missingExecutableText = message("sdk.create.custom.venv.missing.text", name),
labelText = message("sdk.create.custom.venv.executable.path", pyTool.presentableName),
missingExecutableText = message("sdk.create.custom.venv.missing.text", pyTool.presentableName),
installAction = createInstallFix(errorSink),
)
@@ -112,7 +114,7 @@ internal abstract class CustomNewEnvironmentCreator<P : PathHolder>(
*/
@RequiresEdt
protected fun createInstallFix(errorSink: ErrorSink): ActionLink {
return ActionLink(message("sdk.create.custom.venv.install.fix.title.using.pip", name)) {
return ActionLink(message("sdk.create.custom.venv.install.fix.title", pyTool.presentableName)) {
PythonSdkFlavor.clearExecutablesCache()
installExecutable(errorSink)
runWithModalProgressBlocking(ModalTaskOwner.guess(), message("sdk.create.custom.venv.progress.title.detect.executable")) {
@@ -122,55 +124,27 @@ internal abstract class CustomNewEnvironmentCreator<P : PathHolder>(
}
/**
* Downloads the selected downloadable env (if selected), then installs the necessary executable in the Python environment.
*
* Initiates a blocking modal progress task to:
* 1. Ensure that the environment is downloaded (if selected).
* 2. Ensure that pip is installed.
* 3. Install the executable (specified by `name`) using either a custom installation script or via pip.
* Installs the [pyTool] executable behind a single modal progress via its `performToolInstallation`
* extension (prefers `uv tool install`, falls back to a pip install into a system Python). On
* success the resolved launcher is persisted.
*/
@RequiresEdt
private fun installExecutable(errorSink: ErrorSink) {
val baseInterpreter = model.state.baseInterpreter.get()
val installedSdk = when (baseInterpreter) {
is InstallableSelectableInterpreter -> installBaseSdk(baseInterpreter.installableSdk)
?.let {
val sdkWrapper =
runWithModalProgressBlocking(ModalTaskOwner.guess(), message("sdk.create.custom.venv.progress.title.detect.executable")) {
model.fileSystem.wrapSdk(it)
}
val installed = model.addInstalledInterpreter(sdkWrapper.homePath, baseInterpreter.pythonInfo)
model.state.baseInterpreter.set(installed)
installed
}
is DetectedSelectableInterpreter, is ExistingSelectableInterpreter, is ManuallyAddedSelectableInterpreter, null -> null
}
// installedSdk is null when the selected sdk isn't downloadable
// model.state.baseInterpreter could be null if no SDK was selected
val pythonExecutablePath = installedSdk?.homePath ?: model.state.baseInterpreter.get()?.homePath
val pythonExecutable = pythonExecutablePath?.let { model.fileSystem.getBinaryToExec(it) } ?: return
runWithModalProgressBlocking(ModalTaskOwner.guess(), message("sdk.create.custom.venv.install.fix.title.using.pip", name)) {
val versionArgs: List<String> = installationVersion?.let { listOf("-v", it) } ?: emptyList()
when (val r = installExecutableViaPythonScript(pythonExecutable, "-n", name, *versionArgs.toTypedArray())) {
is Result.Success -> {
val pathHolder = PathHolder.Eel(r.result)
savePathToExecutableToProperties(pathHolder as? P)
}
is Result.Failure -> {
errorSink.emit(r.error)
}
runWithModalProgressBlocking(ModalTaskOwner.guess(), message("sdk.create.custom.venv.install.fix.title", pyTool.presentableName)) {
val eel = model.projectPathFlows.projectPath.first()?.getEelDescriptor()?.toEelApi() ?: localEel
when (val r = pyTool.performToolInstallation(eel)) {
is Result.Success -> savePathToExecutableToProperties(PathHolder.Eel(r.result) as? P)
is Result.Failure -> errorSink.emit(r.error)
}
}
}
internal abstract val interpreterType: InterpreterType
internal abstract val toolValidator: ToolValidator<P>
/** The tool this creator installs; drives [installExecutable] via [performToolInstallation]. */
internal abstract val pyTool: PyTool
internal open val installationVersion: String? = null
internal abstract val toolValidator: ToolValidator<P>
protected abstract suspend fun setupEnvSdk(moduleBasePath: Path): PyResult<Sdk>
@@ -19,7 +19,7 @@ import com.intellij.openapi.ui.validation.and
import com.intellij.openapi.wm.IdeFocusManager
import com.intellij.python.community.common.tools.ToolId
import com.intellij.python.community.impl.conda.icons.PythonCommunityImplCondaIcons
import com.intellij.python.community.impl.pipenv.PIPENV_ICON
import com.intellij.python.community.impl.pipenv.icons.PythonCommunityImplPipenvIcons
import com.intellij.python.community.impl.poetry.common.POETRY_TOOL_ID
import com.intellij.python.community.impl.poetry.common.icons.PythonCommunityImplPoetryCommonIcons
import com.intellij.python.hatch.icons.PythonHatchIcons
@@ -145,7 +145,7 @@ enum class PythonSupportedEnvironmentManagers(
VIRTUALENV(VENV_TOOL_ID, "sdk.create.custom.virtualenv", PythonVenvIcons.VirtualEnv, sshAutoUploadRequired = false, { true }),
CONDA(CONDA_TOOL_ID, "sdk.create.custom.conda", PythonCommunityImplCondaIcons.Anaconda, sshAutoUploadRequired = false, { true }),
POETRY(POETRY_TOOL_ID, "sdk.create.custom.poetry", PythonCommunityImplPoetryCommonIcons.Poetry, sshAutoUploadRequired = false),
PIPENV(PIPENV_TOOL_ID, "sdk.create.custom.pipenv", PIPENV_ICON, sshAutoUploadRequired = false),
PIPENV(PIPENV_TOOL_ID, "sdk.create.custom.pipenv", PythonCommunityImplPipenvIcons.PythonClosed, sshAutoUploadRequired = false),
UV(UV_TOOL_ID, "sdk.create.custom.uv", PythonUvCommonIcons.UV, sshAutoUploadRequired = true, { true }),
HATCH(HATCH_TOOL_ID, "sdk.create.custom.hatch", PythonHatchIcons.Logo, sshAutoUploadRequired = false),
PYTHON(VENV_TOOL_ID, "sdk.create.custom.python", PythonParserIcons.PythonFile, sshAutoUploadRequired = false, { true })
@@ -10,6 +10,8 @@ import com.intellij.openapi.vfs.VfsUtilCore
import com.intellij.platform.eel.provider.localEel
import com.intellij.platform.util.progress.withProgressText
import com.intellij.python.hatch.HatchConfiguration
import com.intellij.python.hatch.HatchPyTool
import com.intellij.python.pytools.PyTool
import com.intellij.python.hatch.HatchVirtualEnvironment
import com.intellij.python.hatch.getHatchService
import com.intellij.ui.dsl.builder.Panel
@@ -35,8 +37,9 @@ import java.nio.file.Path
internal class HatchNewEnvironmentCreator<P : PathHolder>(
override val model: PythonMutableTargetAddInterpreterModel<P>,
errorSink: ErrorSink,
) : CustomNewEnvironmentCreator<P>("hatch", model, errorSink) {
) : CustomNewEnvironmentCreator<P>(model, errorSink) {
override val interpreterType: InterpreterType = InterpreterType.HATCH
override val pyTool: PyTool = HatchPyTool.getInstance()
override val toolValidator: ToolValidator<P> = model.hatchViewModel.toolValidator
private lateinit var hatchFormFields: HatchFormFields<P>
override val toolExecutable: ObservableProperty<ValidatedPath.Executable<P>?> = model.hatchViewModel.hatchExecutable
@@ -87,7 +90,7 @@ internal class HatchNewEnvironmentCreator<P : PathHolder>(
?: return Result.failure(HatchUIError.HatchEnvironmentIsNotSelected())
val basePythonBinaryEelPath = when (basePythonBinaryPath) {
is PathHolder.Eel -> basePythonBinaryPath.path
else -> return PyResult.localizedError(PyBundle.message("target.is.not.supported", basePythonBinaryPath))
else -> return PyResult.localizedError(message("target.is.not.supported", basePythonBinaryPath))
}
val hatchExecutablePath = when (val hatchBinary = model.hatchViewModel.hatchExecutable.get()?.pathHolder) {
is PathHolder.Eel -> hatchBinary.path
@@ -4,7 +4,9 @@ package com.jetbrains.python.sdk.add.v2.pipenv
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.observable.properties.ObservableProperty
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.python.community.impl.pipenv.PipEnvPyTool
import com.intellij.python.community.impl.pipenv.pipenvPath
import com.intellij.python.pytools.PyTool
import com.intellij.platform.util.progress.withProgressText
import com.jetbrains.python.PyBundle
import com.jetbrains.python.PyBundle.message
@@ -21,8 +23,9 @@ import com.jetbrains.python.sdk.pipenv.setupPipEnvSdkWithProgressReport
import com.jetbrains.python.statistics.InterpreterType
import java.nio.file.Path
internal class EnvironmentCreatorPip<P : PathHolder>(model: PythonMutableTargetAddInterpreterModel<P>, errorSink: ErrorSink) : CustomNewEnvironmentCreator<P>("pipenv", model, errorSink) {
internal class EnvironmentCreatorPip<P : PathHolder>(model: PythonMutableTargetAddInterpreterModel<P>, errorSink: ErrorSink) : CustomNewEnvironmentCreator<P>(model, errorSink) {
override val interpreterType: InterpreterType = InterpreterType.PIPENV
override val pyTool: PyTool = PipEnvPyTool.getInstance()
override val toolValidator: ToolValidator<P> = model.pipenvViewModel.toolValidator
override val toolExecutable: ObservableProperty<ValidatedPath.Executable<P>?> = model.pipenvViewModel.pipenvExecutable
override val toolExecutablePersister: suspend (P) -> Unit = { pathHolder ->
@@ -40,7 +43,7 @@ internal class EnvironmentCreatorPip<P : PathHolder>(model: PythonMutableTargetA
installPackages = false
)
}
else -> PyResult.localizedError(PyBundle.message("target.is.not.supported", basePythonBinaryPath))
else -> PyResult.localizedError(message("target.is.not.supported", basePythonBinaryPath))
}
}
}
@@ -14,7 +14,9 @@ import com.intellij.openapi.observable.properties.ObservableProperty
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.ui.validation.DialogValidationRequestor
import com.intellij.openapi.vfs.VirtualFileManager
import com.intellij.python.community.impl.poetry.backend.PoetryPyTool
import com.intellij.python.community.impl.poetry.common.poetryPath
import com.intellij.python.pytools.PyTool
import com.intellij.ui.dsl.builder.Panel
import com.intellij.ui.dsl.builder.bindSelected
import com.intellij.platform.util.progress.withProgressText
@@ -54,10 +56,10 @@ internal class EnvironmentCreatorPoetry<P : PathHolder>(
model: PythonMutableTargetAddInterpreterModel<P>,
private val module: Module?,
errorSink: ErrorSink,
) : CustomNewEnvironmentCreator<P>("poetry", model, errorSink) {
) : CustomNewEnvironmentCreator<P>(model, errorSink) {
override val interpreterType: InterpreterType = InterpreterType.POETRY
override val pyTool: PyTool = PoetryPyTool.getInstance()
override val toolValidator: ToolValidator<P> = model.poetryViewModel.toolValidator
override val installationVersion: String = "1.8.0"
override val toolExecutable: ObservableProperty<ValidatedPath.Executable<P>?> = model.poetryViewModel.poetryExecutable
override val toolExecutablePersister: suspend (P) -> Unit = { pathHolder ->
savePathForEelOnly(pathHolder) { path -> PropertiesComponent.getInstance().poetryPath = path.toString() }
@@ -129,7 +131,7 @@ internal class EnvironmentCreatorPoetry<P : PathHolder>(
inProjectEnv = isInProjectEnvFlow.value,
)
}
else -> PyResult.localizedError(PyBundle.message("target.is.not.supported", basePythonBinaryPath))
else -> PyResult.localizedError(message("target.is.not.supported", basePythonBinaryPath))
}
}
@@ -147,7 +149,7 @@ internal class EnvironmentCreatorPoetry<P : PathHolder>(
private fun addInProjectCheckbox(panel: Panel) {
with(panel) {
row("") {
checkBox(PyBundle.message("python.sdk.poetry.dialog.add.new.environment.in.project.checkbox"))
checkBox(message("python.sdk.poetry.dialog.add.new.environment.in.project.checkbox"))
.bindSelected(isInProjectEnvProp)
}
}
@@ -11,6 +11,8 @@ import com.intellij.openapi.ui.ComboBox
import com.intellij.openapi.ui.validation.DialogValidationRequestor
import com.intellij.python.pyproject.PY_PROJECT_TOML
import com.intellij.python.pyproject.PyProjectToml
import com.intellij.python.uv.backend.UvPyTool
import com.intellij.python.pytools.PyTool
import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.Panel
import com.intellij.ui.dsl.builder.bindItem
@@ -70,8 +72,9 @@ internal class EnvironmentCreatorUv<P : PathHolder>(
model: PythonMutableTargetAddInterpreterModel<P>,
private val module: Module?,
errorSink: ErrorSink,
) : CustomNewEnvironmentCreator<P>("uv", model, errorSink) {
) : CustomNewEnvironmentCreator<P>(model, errorSink) {
override val interpreterType: InterpreterType = InterpreterType.UV
override val pyTool: PyTool = UvPyTool.getInstance()
override val toolValidator: ToolValidator<P> = model.uvViewModel.toolValidator
private val executableFlow = MutableStateFlow(model.uvViewModel.uvExecutable.get())
private val pythonVersion: ObservableMutableProperty<Version?> = propertyGraph.property(null)
@@ -6,35 +6,18 @@ import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.module.Module
import com.intellij.openapi.util.Disposer
import com.intellij.python.community.common.tools.ToolId
import com.intellij.python.community.services.systemPython.SystemPythonService
import com.intellij.python.pyproject.model.api.SuggestedSdk
import com.intellij.python.pyproject.model.api.suggestSdk
import com.jetbrains.python.PyBundle
import com.jetbrains.python.PythonPluginDisposable
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.errorProcessing.ErrorSink
import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.sdk.configuration.suppressors.PyPackageRequirementsInspectionSuppressor
import com.jetbrains.python.sdk.configuration.suppressors.TipOfTheDaySuppressor
import com.jetbrains.python.sdk.configurePythonSdk
import com.jetbrains.python.sdk.installExecutableViaPythonScript
import com.jetbrains.python.errorProcessing.ErrorSink
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.nio.file.Path
object PyProjectSdkConfiguration {
internal suspend fun installToolAndShowErrorIfNeeded(module: Module, pathPersister: (Path) -> Unit, toolToInstall: String) {
performToolInstallation(pathPersister, toolToInstall).errorOrNull?.also {
ErrorSink().emit(it, module.project)
}
}
private suspend fun performToolInstallation(pathPersister: (Path) -> Unit, toolToInstall: String): PyResult<Unit> {
val systemPython = SystemPythonService().findSystemPythons().firstOrNull()
?: return PyResult.localizedError(PyBundle.message("sdk.cannot.find.python"))
return installExecutableViaPythonScript(systemPython.asExecutablePython.binary, "-n", toolToInstall).mapSuccess(pathPersister)
}
suspend fun setSdkUsingCreateSdkInfo(
module: Module, createSdkInfoWithTool: CreateSdkInfoWithTool,
): Boolean = withContext(Dispatchers.Default) {
@@ -0,0 +1,85 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.sdk.configuration
import com.intellij.platform.eel.EelApi
import com.intellij.python.community.execService.BinOnEel
import com.intellij.python.community.services.systemPython.SystemPython
import com.intellij.python.community.services.systemPython.SystemPythonService
import com.intellij.python.pytools.InstalledInfo
import com.intellij.python.pytools.PyTool
import com.intellij.python.pytools.PyToolManager
import com.intellij.python.pytools.PyToolManagerProvider
import com.intellij.python.pytools.configuration.ConfigurablePyTool
import com.intellij.python.pytools.getToolVersion
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.getOrNull
import com.jetbrains.python.packaging.PyPackageVersionNormalizer
import com.jetbrains.python.packaging.repository.PyPiPackageRepository
import com.jetbrains.python.sdk.add.v2.FileSystem
import com.jetbrains.python.sdk.add.v2.PathHolder
import com.jetbrains.python.sdk.add.v2.toFileSystem
import com.jetbrains.python.sdk.impl.PySdkBundle
import com.jetbrains.python.sdk.installExecutableViaPythonScript
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.annotations.ApiStatus
import java.nio.file.Path
/**
* Terminal [PyToolManagerProvider] fallback: yields a manager that pip-installs into the first system
* Python of the target environment. Registered last, so it is used only when no higher-priority
* provider (e.g. uv) can operate there.
*/
@ApiStatus.Internal
class SystemPythonToolManagerProvider : PyToolManagerProvider {
override suspend fun forEel(eel: EelApi): PyToolManager? {
val systemPython = SystemPythonService().findSystemPythons(eelApi = eel).firstOrNull() ?: return null
return SystemPythonToolManager(eel.toFileSystem(), systemPython)
}
}
/** pip-installs tools into [systemPython] via the `pycharm_package_installer.py` helper. */
private class SystemPythonToolManager(
private val fileSystem: FileSystem<PathHolder.Eel>,
private val systemPython: SystemPython,
) : PyToolManager {
override suspend fun install(tool: PyTool): PyResult<Path> {
installExecutableViaPythonScript(systemPython.asExecutablePython.binary, "-n", tool.packageName.name).getOr { return it }
val executable = fileSystem.detectTool(tool.packageName.name)
?: return PyResult.localizedError(PySdkBundle.message("cannot.find.executable", tool.packageName.name, fileSystem.userReadableName))
return Result.success(executable.path)
}
/** The pip helper always installs the latest release, so an upgrade is just a fresh install. */
override suspend fun upgrade(tool: PyTool): PyResult<Path> = install(tool)
/**
* Every configurable tool that is actually installed (resolved on [fileSystem]), with its `--version`
* probed and the latest release looked up from PyPI. When PyPI is unreachable the latest version falls
* back to the installed one (i.e. reported as up to date).
*/
override suspend fun list(): Map<PyTool, InstalledInfo> {
return PyTool.EP_NAME.extensionList.filter { it is ConfigurablePyTool }.mapNotNull { tool ->
val name = tool.packageName.name
val executable = fileSystem.detectTool(name) ?: return@mapNotNull null
val installed = BinOnEel(executable.path).getToolVersion(name).getOrNull()?.value ?: return@mapNotNull null
val latest = latestPyPiVersion(name) ?: installed
tool to InstalledInfo(path = executable.path, installedVersion = installed, latestVersion = latest)
}.toMap()
}
/**
* Latest stable release of [packageName] from PyPI, or `null` if it can't be determined. Queried
* app-level through [PyPiPackageRepository] (no project needed); `availableVersions` come back sorted
* newest-first, and we skip pre-/dev-releases to match the default "stable only" upgrade policy.
*/
private suspend fun latestPyPiVersion(packageName: String): String? {
val details = withContext(Dispatchers.IO) { PyPiPackageRepository.buildPackageDetails(packageName) }.getOrNull()
?: return null
return details.availableVersions.firstOrNull { version ->
val normalized = PyPackageVersionNormalizer.normalize(version)
normalized == null || (normalized.pre == null && normalized.dev == null)
}
}
}
@@ -1,7 +1,7 @@
// Copyright 2000-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.jetbrains.python.sdk.pipenv
import com.intellij.python.community.impl.pipenv.PIPENV_ICON
import com.intellij.python.community.impl.pipenv.PipEnvPyTool
import com.jetbrains.python.sdk.flavors.CPythonSdkFlavor
import com.jetbrains.python.sdk.flavors.PyFlavorData
import com.jetbrains.python.sdk.flavors.PythonFlavorProvider
@@ -10,7 +10,7 @@ import javax.swing.Icon
internal object PyPipEnvSdkFlavor : CPythonSdkFlavor<PyFlavorData.Empty>() {
override fun getIcon(): Icon = PIPENV_ICON
override fun getIcon(): Icon = PipEnvPyTool.getInstance().icon
override fun getFlavorDataClass(): Class<PyFlavorData.Empty> = PyFlavorData.Empty::class.java
override fun isValidSdkPath(pythonBinaryPath: Path): Boolean = false
@@ -42,7 +42,7 @@ internal class PyUvSdkConfiguration : PyProjectTomlConfigurationExtension {
EnvCheckerResult.SuggestToolInstallation(
toolToInstall = toolName,
pathPersister = pathPersister,
intentionName = PyBundle.message("sdk.create.custom.venv.install.fix.title.using.pip", toolName)
intentionName = PyBundle.message("sdk.create.custom.venv.install.fix.title", toolName)
)
} else baseCheckResult
}