From ca1cb3145ac8147fdc9688cddd28ce18666e19c9 Mon Sep 17 00:00:00 2001 From: Vitaly Legchilkin Date: Fri, 3 Jul 2026 10:47:22 +0200 Subject: [PATCH] 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 --- python/BUILD.bazel | 2 + python/intellij.python.community.impl.iml | 1 + python/pipenv/BUILD.bazel | 4 + .../intellij.python.community.impl.pipenv.iml | 2 + .../intellij.python.community.impl.pipenv.xml | 3 + .../messages/PyPipenvBundle.properties | 1 + .../community/impl/pipenv/PipEnvPyTool.kt | 28 ++ .../impl/pipenv/PipenvIconMapping.kt | 2 +- .../community/impl/pipenv/PyPipenvBundle.kt | 16 ++ .../python/community/impl/pipenv/icon.kt | 2 +- .../intellij.python.community.impl.xml | 4 +- .../messages/PyBundle.properties | 1 - .../com/intellij/python/black/BlackPyTool.kt | 11 +- .../resources/intellij.python.hatch.xml | 1 + .../messages/PyHatchBundle.properties | 1 + .../com/intellij/python/hatch/HatchPyTool.kt | 27 ++ python/python-poetry/backend/BUILD.bazel | 12 + ...j.python.community.impl.poetry.backend.iml | 6 + ...j.python.community.impl.poetry.backend.xml | 4 + .../messages/PyPoetryBundle.properties | 1 + .../impl/poetry/backend/PoetryPyTool.kt | 27 ++ .../impl/poetry/backend/PyPoetryBundle.kt | 16 ++ python/python-pytools-ui/BUILD.bazel | 4 + .../intellij.python.pytools.ui.iml | 2 + .../messages/PyToolsUiBundle.properties | 44 +--- ...xternalToolsSearchableOptionContributor.kt | 3 +- ...rovider.kt => PyLspToolFeaturesSummary.kt} | 11 - .../PyExternalToolsCellRenderers.kt | 3 +- .../PyExternalToolsConfigurable.kt | 8 +- .../configuration/PyExternalToolsRowModel.kt | 18 +- .../ui/configuration/PyExternalToolsTable.kt | 14 +- .../configuration/PyExternalToolsTooltips.kt | 2 +- ...oller.kt => PyToolManagementController.kt} | 247 ++++++------------ .../resources/intellij.python.pytools.xml | 3 + .../messages/PyToolsBundle.properties | 1 + .../src/com/intellij/python/pytools/PyTool.kt | 9 + .../com/intellij/python/pytools/PyToolExt.kt | 32 +++ .../intellij/python/pytools/PyToolManager.kt | 38 +++ .../python/pytools/PyToolManagerProvider.kt | 29 ++ .../configuration/ConfigurablePyTool.kt | 15 ++ .../intellij/python/pytools/lsp/PyLspTool.kt | 4 - .../pytools/PyToolsStateSerializationTest.kt | 2 + python/python-uv/backend/BUILD.bazel | 2 + .../backend/intellij.python.uv.backend.iml | 1 + .../resources/intellij.python.uv.backend.xml | 4 + .../resources/messages/PyUvBundle.properties | 1 + .../intellij/python/uv/backend/PyUvBundle.kt | 16 ++ .../intellij/python/uv/backend/UvPyTool.kt | 29 ++ .../com/intellij/python/uv/backend/UvTool.kt | 20 ++ .../uv/backend/UvToolManagerProvider.kt | 76 ++++++ .../python/uv/backend/cli/uv/UvTool.kt | 101 ++++--- .../junit5Tests/env/tests/uv/UvCliTest.kt | 22 +- .../InterpreterSettingsQuickFix.kt | 20 +- .../configuration/PyPoetrySdkConfiguration.kt | 7 +- .../sdk/add/v2/CustomNewEnvironmentCreator.kt | 64 ++--- .../com/jetbrains/python/sdk/add/v2/common.kt | 4 +- .../v2/hatch/HatchNewEnvironmentCreator.kt | 7 +- .../add/v2/pipenv/EnvironmentCreatorPip.kt | 7 +- .../add/v2/poetry/EnvironmentCreatorPoetry.kt | 10 +- .../sdk/add/v2/uv/EnvironmentCreatorUv.kt | 5 +- .../PyProjectSdkConfiguration.kt | 19 +- .../SystemPythonToolManagerProvider.kt | 85 ++++++ .../python/sdk/pipenv/PyPipEnvSdkFlavor.kt | 4 +- .../sdk/configuration/PyUvSdkConfiguration.kt | 2 +- 64 files changed, 779 insertions(+), 388 deletions(-) create mode 100644 python/pipenv/resources/messages/PyPipenvBundle.properties create mode 100644 python/pipenv/src/com/intellij/python/community/impl/pipenv/PipEnvPyTool.kt create mode 100644 python/pipenv/src/com/intellij/python/community/impl/pipenv/PyPipenvBundle.kt create mode 100644 python/python-hatch/src/com/intellij/python/hatch/HatchPyTool.kt create mode 100644 python/python-poetry/backend/resources/messages/PyPoetryBundle.properties create mode 100644 python/python-poetry/backend/src/com/intellij/python/community/impl/poetry/backend/PoetryPyTool.kt create mode 100644 python/python-poetry/backend/src/com/intellij/python/community/impl/poetry/backend/PyPoetryBundle.kt rename python/python-pytools-ui/src/com/intellij/python/pytools/ui/{PyToolDetailConfigurableProvider.kt => PyLspToolFeaturesSummary.kt} (63%) rename python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/{UvController.kt => PyToolManagementController.kt} (50%) create mode 100644 python/python-pytools/src/com/intellij/python/pytools/PyToolManager.kt create mode 100644 python/python-pytools/src/com/intellij/python/pytools/PyToolManagerProvider.kt create mode 100644 python/python-pytools/src/com/intellij/python/pytools/configuration/ConfigurablePyTool.kt create mode 100644 python/python-uv/backend/resources/messages/PyUvBundle.properties create mode 100644 python/python-uv/backend/src/com/intellij/python/uv/backend/PyUvBundle.kt create mode 100644 python/python-uv/backend/src/com/intellij/python/uv/backend/UvPyTool.kt create mode 100644 python/python-uv/backend/src/com/intellij/python/uv/backend/UvTool.kt create mode 100644 python/python-uv/backend/src/com/intellij/python/uv/backend/UvToolManagerProvider.kt create mode 100644 python/src/com/jetbrains/python/sdk/configuration/SystemPythonToolManagerProvider.kt diff --git a/python/BUILD.bazel b/python/BUILD.bazel index 13dcd5ab5383..77e640df95a2 100644 --- a/python/BUILD.bazel +++ b/python/BUILD.bazel @@ -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 diff --git a/python/intellij.python.community.impl.iml b/python/intellij.python.community.impl.iml index a4cc5aa5d061..0a2744e47ec9 100644 --- a/python/intellij.python.community.impl.iml +++ b/python/intellij.python.community.impl.iml @@ -208,5 +208,6 @@ + \ No newline at end of file diff --git a/python/pipenv/BUILD.bazel b/python/pipenv/BUILD.bazel index f9060a887a75..2dfe7a6efe34 100644 --- a/python/pipenv/BUILD.bazel +++ b/python/pipenv/BUILD.bazel @@ -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 diff --git a/python/pipenv/intellij.python.community.impl.pipenv.iml b/python/pipenv/intellij.python.community.impl.pipenv.iml index fc9334f002b3..97bcac6ea892 100644 --- a/python/pipenv/intellij.python.community.impl.pipenv.iml +++ b/python/pipenv/intellij.python.community.impl.pipenv.iml @@ -15,5 +15,7 @@ + + \ No newline at end of file diff --git a/python/pipenv/resources/intellij.python.community.impl.pipenv.xml b/python/pipenv/resources/intellij.python.community.impl.pipenv.xml index 8c28cb423c63..c9f57c45fc3d 100644 --- a/python/pipenv/resources/intellij.python.community.impl.pipenv.xml +++ b/python/pipenv/resources/intellij.python.community.impl.pipenv.xml @@ -2,8 +2,11 @@ + + + diff --git a/python/pipenv/resources/messages/PyPipenvBundle.properties b/python/pipenv/resources/messages/PyPipenvBundle.properties new file mode 100644 index 000000000000..15e84d3d0ee9 --- /dev/null +++ b/python/pipenv/resources/messages/PyPipenvBundle.properties @@ -0,0 +1 @@ +python.pipenv.tool.description=A Python dependency and virtual environment manager. diff --git a/python/pipenv/src/com/intellij/python/community/impl/pipenv/PipEnvPyTool.kt b/python/pipenv/src/com/intellij/python/community/impl/pipenv/PipEnvPyTool.kt new file mode 100644 index 000000000000..bf5f9583c67d --- /dev/null +++ b/python/pipenv/src/com/intellij/python/community/impl/pipenv/PipEnvPyTool.kt @@ -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) + } +} diff --git a/python/pipenv/src/com/intellij/python/community/impl/pipenv/PipenvIconMapping.kt b/python/pipenv/src/com/intellij/python/community/impl/pipenv/PipenvIconMapping.kt index 30460ecb119f..a791a96a18d3 100644 --- a/python/pipenv/src/com/intellij/python/community/impl/pipenv/PipenvIconMapping.kt +++ b/python/pipenv/src/com/intellij/python/community/impl/pipenv/PipenvIconMapping.kt @@ -8,6 +8,6 @@ import com.intellij.python.processOutput.common.ProcessOutputIconMapping internal class PipenvIconMapping : ProcessOutputIconMapping() { override val mapping: Map = mapOf( - ProcessBinaryFileName("pipenv") to ProcessIcon(PIPENV_ICON, PythonCommunityImplPipenvIcons::class.java) + ProcessBinaryFileName("pipenv") to ProcessIcon(PythonCommunityImplPipenvIcons.PythonClosed, PythonCommunityImplPipenvIcons::class.java) ) } \ No newline at end of file diff --git a/python/pipenv/src/com/intellij/python/community/impl/pipenv/PyPipenvBundle.kt b/python/pipenv/src/com/intellij/python/community/impl/pipenv/PyPipenvBundle.kt new file mode 100644 index 000000000000..6ab6a213572f --- /dev/null +++ b/python/pipenv/src/com/intellij/python/community/impl/pipenv/PyPipenvBundle.kt @@ -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) + } +} diff --git a/python/pipenv/src/com/intellij/python/community/impl/pipenv/icon.kt b/python/pipenv/src/com/intellij/python/community/impl/pipenv/icon.kt index 6969c436a084..50ec0bf37df0 100644 --- a/python/pipenv/src/com/intellij/python/community/impl/pipenv/icon.kt +++ b/python/pipenv/src/com/intellij/python/community/impl/pipenv/icon.kt @@ -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 \ No newline at end of file diff --git a/python/pluginResources/intellij.python.community.impl.xml b/python/pluginResources/intellij.python.community.impl.xml index eb3f5ee04782..d793ccc730ef 100644 --- a/python/pluginResources/intellij.python.community.impl.xml +++ b/python/pluginResources/intellij.python.community.impl.xml @@ -12,8 +12,8 @@ - + @@ -21,6 +21,7 @@ + @@ -49,6 +50,7 @@ + diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties index 564a737d2027..9cb0f1695291 100644 --- a/python/pluginResources/messages/PyBundle.properties +++ b/python/pluginResources/messages/PyBundle.properties @@ -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: diff --git a/python/python-black/src/com/intellij/python/black/BlackPyTool.kt b/python/python-black/src/com/intellij/python/black/BlackPyTool.kt index 2224ec17194c..cdc57fab325c 100644 --- a/python/python-black/src/com/intellij/python/black/BlackPyTool.kt +++ b/python/python-black/src/com/intellij/python/black/BlackPyTool.kt @@ -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 diff --git a/python/python-hatch/resources/intellij.python.hatch.xml b/python/python-hatch/resources/intellij.python.hatch.xml index 37cb8eb22c15..22b42c1e4aa1 100644 --- a/python/python-hatch/resources/intellij.python.hatch.xml +++ b/python/python-hatch/resources/intellij.python.hatch.xml @@ -22,6 +22,7 @@ + diff --git a/python/python-hatch/resources/messages/PyHatchBundle.properties b/python/python-hatch/resources/messages/PyHatchBundle.properties index 4ac3f4397a48..f5a8c9e19938 100644 --- a/python/python-hatch/resources/messages/PyHatchBundle.properties +++ b/python/python-hatch/resources/messages/PyHatchBundle.properties @@ -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} diff --git a/python/python-hatch/src/com/intellij/python/hatch/HatchPyTool.kt b/python/python-hatch/src/com/intellij/python/hatch/HatchPyTool.kt new file mode 100644 index 000000000000..cabb6fb0bfb5 --- /dev/null +++ b/python/python-hatch/src/com/intellij/python/hatch/HatchPyTool.kt @@ -0,0 +1,27 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.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) + } +} diff --git a/python/python-poetry/backend/BUILD.bazel b/python/python-poetry/backend/BUILD.bazel index 053f88ca352b..d10d704ea9b4 100644 --- a/python/python-poetry/backend/BUILD.bazel +++ b/python/python-poetry/backend/BUILD.bazel @@ -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 diff --git a/python/python-poetry/backend/intellij.python.community.impl.poetry.backend.iml b/python/python-poetry/backend/intellij.python.community.impl.poetry.backend.iml index 89bc293369be..d9377247dc47 100644 --- a/python/python-poetry/backend/intellij.python.community.impl.poetry.backend.iml +++ b/python/python-poetry/backend/intellij.python.community.impl.poetry.backend.iml @@ -15,5 +15,11 @@ + + + + + + \ No newline at end of file diff --git a/python/python-poetry/backend/resources/intellij.python.community.impl.poetry.backend.xml b/python/python-poetry/backend/resources/intellij.python.community.impl.poetry.backend.xml index 501bf25cb069..5119b8b36cce 100644 --- a/python/python-poetry/backend/resources/intellij.python.community.impl.poetry.backend.xml +++ b/python/python-poetry/backend/resources/intellij.python.community.impl.poetry.backend.xml @@ -7,8 +7,12 @@ + + + + \ No newline at end of file diff --git a/python/python-poetry/backend/resources/messages/PyPoetryBundle.properties b/python/python-poetry/backend/resources/messages/PyPoetryBundle.properties new file mode 100644 index 000000000000..134e23d634a0 --- /dev/null +++ b/python/python-poetry/backend/resources/messages/PyPoetryBundle.properties @@ -0,0 +1 @@ +python.poetry.tool.description=A tool for Python dependency management and packaging. diff --git a/python/python-poetry/backend/src/com/intellij/python/community/impl/poetry/backend/PoetryPyTool.kt b/python/python-poetry/backend/src/com/intellij/python/community/impl/poetry/backend/PoetryPyTool.kt new file mode 100644 index 000000000000..f9ec7fd678e1 --- /dev/null +++ b/python/python-poetry/backend/src/com/intellij/python/community/impl/poetry/backend/PoetryPyTool.kt @@ -0,0 +1,27 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.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) + } +} diff --git a/python/python-poetry/backend/src/com/intellij/python/community/impl/poetry/backend/PyPoetryBundle.kt b/python/python-poetry/backend/src/com/intellij/python/community/impl/poetry/backend/PyPoetryBundle.kt new file mode 100644 index 000000000000..7418d5d3da02 --- /dev/null +++ b/python/python-poetry/backend/src/com/intellij/python/community/impl/poetry/backend/PyPoetryBundle.kt @@ -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) + } +} diff --git a/python/python-pytools-ui/BUILD.bazel b/python/python-pytools-ui/BUILD.bazel index 7840b761c8dc..1091bf4225c6 100644 --- a/python/python-pytools-ui/BUILD.bazel +++ b/python/python-pytools-ui/BUILD.bazel @@ -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", diff --git a/python/python-pytools-ui/intellij.python.pytools.ui.iml b/python/python-pytools-ui/intellij.python.pytools.ui.iml index de412be3fdcb..bc9d76efafdc 100644 --- a/python/python-pytools-ui/intellij.python.pytools.ui.iml +++ b/python/python-pytools-ui/intellij.python.pytools.ui.iml @@ -14,6 +14,8 @@ + + diff --git a/python/python-pytools-ui/resources/messages/PyToolsUiBundle.properties b/python/python-pytools-ui/resources/messages/PyToolsUiBundle.properties index 22d6839a4310..93b872e124c3 100644 --- a/python/python-pytools-ui/resources/messages/PyToolsUiBundle.properties +++ b/python/python-pytools-ui/resources/messages/PyToolsUiBundle.properties @@ -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. All actions on save\u2026 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 diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyExternalToolsSearchableOptionContributor.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyExternalToolsSearchableOptionContributor.kt index 65f53064ec2b..2c3553d97ebb 100644 --- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyExternalToolsSearchableOptionContributor.kt +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyExternalToolsSearchableOptionContributor.kt @@ -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, diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyToolDetailConfigurableProvider.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyLspToolFeaturesSummary.kt similarity index 63% rename from python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyToolDetailConfigurableProvider.kt rename to python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyLspToolFeaturesSummary.kt index e8fa63aab7a2..f474c69ff7aa 100644 --- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyToolDetailConfigurableProvider.kt +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/PyLspToolFeaturesSummary.kt @@ -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. diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsCellRenderers.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsCellRenderers.kt index f42b3f90f75b..ea7f83d0ea4e 100644 --- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsCellRenderers.kt +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsCellRenderers.kt @@ -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 diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsConfigurable.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsConfigurable.kt index b801d10e70af..de9ff8351ebb 100644 --- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsConfigurable.kt +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsConfigurable.kt @@ -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) } diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsRowModel.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsRowModel.kt index 1e5b249a162a..52c40c7d315e 100644 --- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsRowModel.kt +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsRowModel.kt @@ -22,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 } diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTable.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTable.kt index 31b8d5441cf2..b8bd5bfbf966 100644 --- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTable.kt +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTable.kt @@ -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 = 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 } diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTooltips.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTooltips.kt index d350143fe0d8..d620fd92b81b 100644 --- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTooltips.kt +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTooltips.kt @@ -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) diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/UvController.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyToolManagementController.kt similarity index 50% rename from python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/UvController.kt rename to python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyToolManagementController.kt index 6a6ccb4295a3..0ed3575dadcd 100644 --- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/UvController.kt +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyToolManagementController.kt @@ -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 = 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 = 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 = 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 = 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 ` 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 = 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 ` 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, + 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) - } } diff --git a/python/python-pytools/resources/intellij.python.pytools.xml b/python/python-pytools/resources/intellij.python.pytools.xml index ed8b827895cc..3f82f3621fcc 100644 --- a/python/python-pytools/resources/intellij.python.pytools.xml +++ b/python/python-pytools/resources/intellij.python.pytools.xml @@ -9,6 +9,9 @@ + diff --git a/python/python-pytools/resources/messages/PyToolsBundle.properties b/python/python-pytools/resources/messages/PyToolsBundle.properties index c18a75351346..204d6a62a512 100644 --- a/python/python-pytools/resources/messages/PyToolsBundle.properties +++ b/python/python-pytools/resources/messages/PyToolsBundle.properties @@ -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 diff --git a/python/python-pytools/src/com/intellij/python/pytools/PyTool.kt b/python/python-pytools/src/com/intellij/python/pytools/PyTool.kt index 4de9cfb24115..a392aaa7a875 100644 --- a/python/python-pytools/src/com/intellij/python/pytools/PyTool.kt +++ b/python/python-pytools/src/com/intellij/python/pytools/PyTool.kt @@ -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 = 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 } + } } } diff --git a/python/python-pytools/src/com/intellij/python/pytools/PyToolExt.kt b/python/python-pytools/src/com/intellij/python/pytools/PyToolExt.kt index 01d19c7f2e13..2666b437419a 100644 --- a/python/python-pytools/src/com/intellij/python/pytools/PyToolExt.kt +++ b/python/python-pytools/src/com/intellij/python/pytools/PyToolExt.kt @@ -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 { + 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 { + val manager = PyToolManagerProvider.managerFor(eel) + ?: return PyResult.localizedError(message("python.tool.install.no.installer", presentableName)) + return manager.upgrade(this) +} diff --git a/python/python-pytools/src/com/intellij/python/pytools/PyToolManager.kt b/python/python-pytools/src/com/intellij/python/pytools/PyToolManager.kt new file mode 100644 index 000000000000..efe60f6e061c --- /dev/null +++ b/python/python-pytools/src/com/intellij/python/pytools/PyToolManager.kt @@ -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 + + /** Upgrades [tool] to the latest version. */ + suspend fun upgrade(tool: PyTool): PyResult + + /** + * 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 +} + +/** + * 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, +) diff --git a/python/python-pytools/src/com/intellij/python/pytools/PyToolManagerProvider.kt b/python/python-pytools/src/com/intellij/python/pytools/PyToolManagerProvider.kt new file mode 100644 index 000000000000..6cdab83d6ed5 --- /dev/null +++ b/python/python-pytools/src/com/intellij/python/pytools/PyToolManagerProvider.kt @@ -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 = + 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) } + } +} diff --git a/python/python-pytools/src/com/intellij/python/pytools/configuration/ConfigurablePyTool.kt b/python/python-pytools/src/com/intellij/python/pytools/configuration/ConfigurablePyTool.kt new file mode 100644 index 000000000000..d5e66bcd1348 --- /dev/null +++ b/python/python-pytools/src/com/intellij/python/pytools/configuration/ConfigurablePyTool.kt @@ -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 +} diff --git a/python/python-pytools/src/com/intellij/python/pytools/lsp/PyLspTool.kt b/python/python-pytools/src/com/intellij/python/pytools/lsp/PyLspTool.kt index 29185c9a1691..8043c7f3faf7 100644 --- a/python/python-pytools/src/com/intellij/python/pytools/lsp/PyLspTool.kt +++ b/python/python-pytools/src/com/intellij/python/pytools/lsp/PyLspTool.kt @@ -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> : 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 { diff --git a/python/python-pytools/tests/testSrc/com/intellij/python/junit5Tests/unit/pytools/PyToolsStateSerializationTest.kt b/python/python-pytools/tests/testSrc/com/intellij/python/junit5Tests/unit/pytools/PyToolsStateSerializationTest.kt index 4aee6a296080..0beee97c667d 100644 --- a/python/python-pytools/tests/testSrc/com/intellij/python/junit5Tests/unit/pytools/PyToolsStateSerializationTest.kt +++ b/python/python-pytools/tests/testSrc/com/intellij/python/junit5Tests/unit/pytools/PyToolsStateSerializationTest.kt @@ -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. diff --git a/python/python-uv/backend/BUILD.bazel b/python/python-uv/backend/BUILD.bazel index 5b2b771d912f..575cbe1096c1 100644 --- a/python/python-uv/backend/BUILD.bazel +++ b/python/python-uv/backend/BUILD.bazel @@ -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 diff --git a/python/python-uv/backend/intellij.python.uv.backend.iml b/python/python-uv/backend/intellij.python.uv.backend.iml index 66b734f4322a..6db957fdfcf9 100644 --- a/python/python-uv/backend/intellij.python.uv.backend.iml +++ b/python/python-uv/backend/intellij.python.uv.backend.iml @@ -19,5 +19,6 @@ + \ No newline at end of file diff --git a/python/python-uv/backend/resources/intellij.python.uv.backend.xml b/python/python-uv/backend/resources/intellij.python.uv.backend.xml index de08f5ab8aa2..89f0980a7395 100644 --- a/python/python-uv/backend/resources/intellij.python.uv.backend.xml +++ b/python/python-uv/backend/resources/intellij.python.uv.backend.xml @@ -15,4 +15,8 @@ + + + + diff --git a/python/python-uv/backend/resources/messages/PyUvBundle.properties b/python/python-uv/backend/resources/messages/PyUvBundle.properties new file mode 100644 index 000000000000..a1b0a2c9871a --- /dev/null +++ b/python/python-uv/backend/resources/messages/PyUvBundle.properties @@ -0,0 +1 @@ +python.uv.tool.description=An extremely fast Python package and project manager, written in Rust. diff --git a/python/python-uv/backend/src/com/intellij/python/uv/backend/PyUvBundle.kt b/python/python-uv/backend/src/com/intellij/python/uv/backend/PyUvBundle.kt new file mode 100644 index 000000000000..3612f7cf6231 --- /dev/null +++ b/python/python-uv/backend/src/com/intellij/python/uv/backend/PyUvBundle.kt @@ -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) + } +} diff --git a/python/python-uv/backend/src/com/intellij/python/uv/backend/UvPyTool.kt b/python/python-uv/backend/src/com/intellij/python/uv/backend/UvPyTool.kt new file mode 100644 index 000000000000..8c814ca6ff11 --- /dev/null +++ b/python/python-uv/backend/src/com/intellij/python/uv/backend/UvPyTool.kt @@ -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) + } +} diff --git a/python/python-uv/backend/src/com/intellij/python/uv/backend/UvTool.kt b/python/python-uv/backend/src/com/intellij/python/uv/backend/UvTool.kt new file mode 100644 index 000000000000..38e7daff1961 --- /dev/null +++ b/python/python-uv/backend/src/com/intellij/python/uv/backend/UvTool.kt @@ -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" diff --git a/python/python-uv/backend/src/com/intellij/python/uv/backend/UvToolManagerProvider.kt b/python/python-uv/backend/src/com/intellij/python/uv/backend/UvToolManagerProvider.kt new file mode 100644 index 000000000000..4ebbc8894a09 --- /dev/null +++ b/python/python-uv/backend/src/com/intellij/python/uv/backend/UvToolManagerProvider.kt @@ -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, + private val uv: Path, +) : PyToolManager { + override suspend fun install(tool: PyTool): PyResult = run(tool, reinstall = false) + + override suspend fun upgrade(tool: PyTool): PyResult = 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 { + 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 { + 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) + } +} diff --git a/python/python-uv/backend/src/com/intellij/python/uv/backend/cli/uv/UvTool.kt b/python/python-uv/backend/src/com/intellij/python/uv/backend/cli/uv/UvTool.kt index 43749ec19a3e..04fb4444f154 100644 --- a/python/python-uv/backend/src/com/intellij/python/uv/backend/cli/uv/UvTool.kt +++ b/python/python-uv/backend/src/com/intellij/python/uv/backend/cli/uv/UvTool.kt @@ -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 { + suspend fun install(name: String, reinstall: Boolean = false): PyResult { 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 { + suspend fun list(showVersionSpecifiers: Boolean = false, showPaths: Boolean = false, outdated: Boolean = false): PyResult> { 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> { - 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> { - 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() + var pending: UvToolListResult? = null + val executables = linkedMapOf() + + 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 = 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 { + suspend fun dir(bin: Boolean = false): PyResult { 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 { - 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 = emptyMap(), +) diff --git a/python/python-uv/tests/testSrc/com/intellij/python/junit5Tests/env/tests/uv/UvCliTest.kt b/python/python-uv/tests/testSrc/com/intellij/python/junit5Tests/env/tests/uv/UvCliTest.kt index 2acd5bb28695..cd3de84e8574 100644 --- a/python/python-uv/tests/testSrc/com/intellij/python/junit5Tests/env/tests/uv/UvCliTest.kt +++ b/python/python-uv/tests/testSrc/com/intellij/python/junit5Tests/env/tests/uv/UvCliTest.kt @@ -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" } } } diff --git a/python/src/com/jetbrains/python/inspections/interpreter/InterpreterSettingsQuickFix.kt b/python/src/com/jetbrains/python/inspections/interpreter/InterpreterSettingsQuickFix.kt index 2824c4e14341..e2b658717c0b 100644 --- a/python/src/com/jetbrains/python/inspections/interpreter/InterpreterSettingsQuickFix.kt +++ b/python/src/com/jetbrains/python/inspections/interpreter/InterpreterSettingsQuickFix.kt @@ -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)) } } } diff --git a/python/src/com/jetbrains/python/poetry/sdk/configuration/PyPoetrySdkConfiguration.kt b/python/src/com/jetbrains/python/poetry/sdk/configuration/PyPoetrySdkConfiguration.kt index 5818fd16974a..acf307bfdf2a 100644 --- a/python/src/com/jetbrains/python/poetry/sdk/configuration/PyPoetrySdkConfiguration.kt +++ b/python/src/com/jetbrains/python/poetry/sdk/configuration/PyPoetrySdkConfiguration.kt @@ -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 diff --git a/python/src/com/jetbrains/python/sdk/add/v2/CustomNewEnvironmentCreator.kt b/python/src/com/jetbrains/python/sdk/add/v2/CustomNewEnvironmentCreator.kt index 8499fae818d4..16f9031e67bb 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/CustomNewEnvironmentCreator.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/CustomNewEnvironmentCreator.kt @@ -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

( - private val name: String, model: PythonMutableTargetAddInterpreterModel

, protected val errorSink: ErrorSink, ) : PythonNewEnvironmentCreator

(model) { @@ -50,8 +52,8 @@ internal abstract class CustomNewEnvironmentCreator

( 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

( */ @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

( } /** - * 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 = 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

+ /** 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

protected abstract suspend fun setupEnvSdk(moduleBasePath: Path): PyResult diff --git a/python/src/com/jetbrains/python/sdk/add/v2/common.kt b/python/src/com/jetbrains/python/sdk/add/v2/common.kt index c7b3f1af46b0..23a98263bf3e 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/common.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/common.kt @@ -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 }) diff --git a/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchNewEnvironmentCreator.kt b/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchNewEnvironmentCreator.kt index 874680600ad2..83243dd52c69 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchNewEnvironmentCreator.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/hatch/HatchNewEnvironmentCreator.kt @@ -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

( override val model: PythonMutableTargetAddInterpreterModel

, errorSink: ErrorSink, -) : CustomNewEnvironmentCreator

("hatch", model, errorSink) { +) : CustomNewEnvironmentCreator

(model, errorSink) { override val interpreterType: InterpreterType = InterpreterType.HATCH + override val pyTool: PyTool = HatchPyTool.getInstance() override val toolValidator: ToolValidator

= model.hatchViewModel.toolValidator private lateinit var hatchFormFields: HatchFormFields

override val toolExecutable: ObservableProperty?> = model.hatchViewModel.hatchExecutable @@ -87,7 +90,7 @@ internal class HatchNewEnvironmentCreator

( ?: 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 diff --git a/python/src/com/jetbrains/python/sdk/add/v2/pipenv/EnvironmentCreatorPip.kt b/python/src/com/jetbrains/python/sdk/add/v2/pipenv/EnvironmentCreatorPip.kt index 8fcd4c21f1db..1d72198b364c 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/pipenv/EnvironmentCreatorPip.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/pipenv/EnvironmentCreatorPip.kt @@ -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

(model: PythonMutableTargetAddInterpreterModel

, errorSink: ErrorSink) : CustomNewEnvironmentCreator

("pipenv", model, errorSink) { +internal class EnvironmentCreatorPip

(model: PythonMutableTargetAddInterpreterModel

, errorSink: ErrorSink) : CustomNewEnvironmentCreator

(model, errorSink) { override val interpreterType: InterpreterType = InterpreterType.PIPENV + override val pyTool: PyTool = PipEnvPyTool.getInstance() override val toolValidator: ToolValidator

= model.pipenvViewModel.toolValidator override val toolExecutable: ObservableProperty?> = model.pipenvViewModel.pipenvExecutable override val toolExecutablePersister: suspend (P) -> Unit = { pathHolder -> @@ -40,7 +43,7 @@ internal class EnvironmentCreatorPip

(model: PythonMutableTargetA installPackages = false ) } - else -> PyResult.localizedError(PyBundle.message("target.is.not.supported", basePythonBinaryPath)) + else -> PyResult.localizedError(message("target.is.not.supported", basePythonBinaryPath)) } } } diff --git a/python/src/com/jetbrains/python/sdk/add/v2/poetry/EnvironmentCreatorPoetry.kt b/python/src/com/jetbrains/python/sdk/add/v2/poetry/EnvironmentCreatorPoetry.kt index 7a9f4f43cc83..542ae4006227 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/poetry/EnvironmentCreatorPoetry.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/poetry/EnvironmentCreatorPoetry.kt @@ -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

( model: PythonMutableTargetAddInterpreterModel

, private val module: Module?, errorSink: ErrorSink, -) : CustomNewEnvironmentCreator

("poetry", model, errorSink) { +) : CustomNewEnvironmentCreator

(model, errorSink) { override val interpreterType: InterpreterType = InterpreterType.POETRY + override val pyTool: PyTool = PoetryPyTool.getInstance() override val toolValidator: ToolValidator

= model.poetryViewModel.toolValidator - override val installationVersion: String = "1.8.0" override val toolExecutable: ObservableProperty?> = 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

( 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

( 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) } } diff --git a/python/src/com/jetbrains/python/sdk/add/v2/uv/EnvironmentCreatorUv.kt b/python/src/com/jetbrains/python/sdk/add/v2/uv/EnvironmentCreatorUv.kt index 0ff29f9c0461..40c6b3f94e9e 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/uv/EnvironmentCreatorUv.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/uv/EnvironmentCreatorUv.kt @@ -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

( model: PythonMutableTargetAddInterpreterModel

, private val module: Module?, errorSink: ErrorSink, -) : CustomNewEnvironmentCreator

("uv", model, errorSink) { +) : CustomNewEnvironmentCreator

(model, errorSink) { override val interpreterType: InterpreterType = InterpreterType.UV + override val pyTool: PyTool = UvPyTool.getInstance() override val toolValidator: ToolValidator

= model.uvViewModel.toolValidator private val executableFlow = MutableStateFlow(model.uvViewModel.uvExecutable.get()) private val pythonVersion: ObservableMutableProperty = propertyGraph.property(null) diff --git a/python/src/com/jetbrains/python/sdk/configuration/PyProjectSdkConfiguration.kt b/python/src/com/jetbrains/python/sdk/configuration/PyProjectSdkConfiguration.kt index 3fc613e710df..6968a7c36215 100644 --- a/python/src/com/jetbrains/python/sdk/configuration/PyProjectSdkConfiguration.kt +++ b/python/src/com/jetbrains/python/sdk/configuration/PyProjectSdkConfiguration.kt @@ -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 { - 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) { diff --git a/python/src/com/jetbrains/python/sdk/configuration/SystemPythonToolManagerProvider.kt b/python/src/com/jetbrains/python/sdk/configuration/SystemPythonToolManagerProvider.kt new file mode 100644 index 000000000000..e48465eecc0f --- /dev/null +++ b/python/src/com/jetbrains/python/sdk/configuration/SystemPythonToolManagerProvider.kt @@ -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, + private val systemPython: SystemPython, +) : PyToolManager { + override suspend fun install(tool: PyTool): PyResult { + 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 = 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 { + 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) + } + } +} diff --git a/python/src/com/jetbrains/python/sdk/pipenv/PyPipEnvSdkFlavor.kt b/python/src/com/jetbrains/python/sdk/pipenv/PyPipEnvSdkFlavor.kt index 8c3e50836b03..cd9af77d000d 100644 --- a/python/src/com/jetbrains/python/sdk/pipenv/PyPipEnvSdkFlavor.kt +++ b/python/src/com/jetbrains/python/sdk/pipenv/PyPipEnvSdkFlavor.kt @@ -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() { - override fun getIcon(): Icon = PIPENV_ICON + override fun getIcon(): Icon = PipEnvPyTool.getInstance().icon override fun getFlavorDataClass(): Class = PyFlavorData.Empty::class.java override fun isValidSdkPath(pythonBinaryPath: Path): Boolean = false diff --git a/python/src/com/jetbrains/python/uv/sdk/configuration/PyUvSdkConfiguration.kt b/python/src/com/jetbrains/python/uv/sdk/configuration/PyUvSdkConfiguration.kt index 1039e7397331..db527c516802 100644 --- a/python/src/com/jetbrains/python/uv/sdk/configuration/PyUvSdkConfiguration.kt +++ b/python/src/com/jetbrains/python/uv/sdk/configuration/PyUvSdkConfiguration.kt @@ -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 }