[python] Support Hatch SDK (PY-60410)

* add new / select existing for local sdks
* create a new project with hatch sdk
* open hatch-managed project

(cherry picked from commit 86e970a39bc44cec34be7c82717806fc4d0009c4)

GitOrigin-RevId: 305e5363337e9120261f72e964e7d9e3c1a62c7c
This commit is contained in:
Vitaly Legchilkin
2025-02-25 15:04:27 +01:00
committed by intellij-monorepo-bot
parent fa9d6dd755
commit 44da124ea0
22 changed files with 621 additions and 26 deletions

View File

@@ -39,6 +39,7 @@ object PythonCommunityPluginModules {
"intellij.python.terminal",
"intellij.python.ml.features",
"intellij.python.pyproject",
"intellij.python.hatch",
)
/**

View File

@@ -38,5 +38,6 @@
<orderEntry type="module" module-name="intellij.python.community.impl.venv" />
<orderEntry type="library" name="jetbrains-annotations" level="project" />
<orderEntry type="module" module-name="intellij.python.pyproject" />
<orderEntry type="module" module-name="intellij.python.hatch" />
</component>
</module>

View File

@@ -21,8 +21,10 @@
serviceImplementation="com.intellij.pycharm.community.ide.impl.PyProjectScopeBuilder"
overrides="true"/>
<refactoring.elementListenerProvider implementation="com.intellij.pycharm.community.ide.impl.miscProject.impl.MiscProjectListenerProvider"/>
<statistics.counterUsagesCollector implementationClass="com.intellij.pycharm.community.ide.impl.miscProject.impl.MiscProjectUsageCollector"/>
<refactoring.elementListenerProvider
implementation="com.intellij.pycharm.community.ide.impl.miscProject.impl.MiscProjectListenerProvider"/>
<statistics.counterUsagesCollector
implementationClass="com.intellij.pycharm.community.ide.impl.miscProject.impl.MiscProjectUsageCollector"/>
<registryKey defaultValue="5" description="Number of primary buttons on welcome screen (other go to 'more actions')"
key="welcome.screen.primaryButtonsCount" restartRequired="true" overrides="true"/>
<applicationInitializedListener implementation="com.intellij.pycharm.community.ide.impl.PyCharmCorePluginConfigurator"/>
@@ -109,8 +111,10 @@
id="pipfile" order="before requirementsTxtOrSetupPy"/>
<projectSdkConfigurationExtension implementation="com.intellij.pycharm.community.ide.impl.configuration.PyPoetrySdkConfiguration"
id="poetry"/>
<projectSdkConfigurationExtension implementation="com.intellij.pycharm.community.ide.impl.configuration.PyHatchSdkConfiguration"
id="hatch" order="after poetry"/>
<projectSdkConfigurationExtension implementation="com.intellij.pycharm.community.ide.impl.configuration.PyUvSdkConfiguration"
id="uv" order="after poetry"/>
id="uv" order="after hatch"/>
</extensions>
<actions resource-bundle="messages.ActionsBundle">

View File

@@ -58,6 +58,7 @@ sdk.dialog.title.setting.up.poetry.environment=Setting Up Poetry Environment
sdk.notification.label.set.up.poetry.environment.from.pyproject.toml.dependencies=File pyproject.toml contains project dependencies. Would you like to set up a poetry environment?
notification.group.pro.advertiser=PyCharm Professional recommended
sdk.set.up.hatch.environment=Set up Hatch 'default' environment
sdk.set.up.uv.environment=Set up an uv environment using {0}
new.project.python.group.name=Python

View File

@@ -0,0 +1,56 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.pycharm.community.ide.impl.configuration
import com.intellij.codeInspection.util.IntentionName
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.module.Module
import com.intellij.openapi.progress.runBlockingCancellable
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil
import com.intellij.platform.ide.progress.runWithModalProgressBlocking
import com.intellij.pycharm.community.ide.impl.PyCharmCommunityCustomizationBundle
import com.intellij.python.hatch.getHatchService
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.jetbrains.python.getOrNull
import com.jetbrains.python.orLogException
import com.jetbrains.python.sdk.hatch.createSdk
import com.jetbrains.python.sdk.configuration.PyProjectSdkConfigurationExtension
class PyHatchSdkConfiguration : PyProjectSdkConfigurationExtension {
companion object {
private val LOGGER = Logger.getInstance(PyHatchSdkConfiguration::class.java)
}
@RequiresBackgroundThread
override fun getIntention(module: Module): @IntentionName String? {
val isReadyAndHaveOwnership = runWithModalProgressBlocking(module.project, "Hatch Project Analysis") {
val hatchService = module.getHatchService().getOr { return@runWithModalProgressBlocking false }
hatchService.isHatchManagedProject().getOrNull() == true
}
val intention = when {
isReadyAndHaveOwnership -> PyCharmCommunityCustomizationBundle.message("sdk.set.up.hatch.environment")
else -> null
}
return intention
}
private fun createSdk(module: Module): Sdk? {
val sdk = runBlockingCancellable {
val hatchService = module.getHatchService().orLogException(LOGGER)
val environment = hatchService?.createVirtualEnvironment()?.orLogException(LOGGER)
environment?.createSdk(module)?.orLogException(LOGGER)
}?.also {
SdkConfigurationUtil.addSdk(it)
}
return sdk
}
@RequiresBackgroundThread
override fun createAndAddSdkForConfigurator(module: Module): Sdk? = createSdk(module)
@RequiresBackgroundThread
override fun createAndAddSdkForInspection(module: Module): Sdk? = createSdk(module)
override fun supportsHeadlessModel(): Boolean = true
}

View File

@@ -144,5 +144,6 @@
<orderEntry type="module" module-name="intellij.python.community.services.internal.impl" />
<orderEntry type="library" name="io.github.z4kn4fein.semver.jvm" level="project" />
<orderEntry type="module" module-name="intellij.python.pyproject" />
<orderEntry type="module" module-name="intellij.python.hatch" />
</component>
</module>

View File

@@ -840,6 +840,8 @@ The Python plug-in provides smart editing for Python scripts. The feature set of
<pythonFlavorProvider implementation="com.jetbrains.python.sdk.uv.UvSdkFlavorProvider"/>
<pythonFlavorProvider implementation="com.jetbrains.python.sdk.hatch.HatchSdkFlavorProvider"/>
<!-- SDK Flavors -->
<pythonSdkFlavor implementation="com.jetbrains.python.sdk.flavors.conda.CondaEnvSdkFlavor"/>
<pythonSdkFlavor implementation="com.jetbrains.python.sdk.flavors.MacPythonSdkFlavor"/>

View File

@@ -551,6 +551,12 @@ sdk.create.custom.venv.install.fix.title=Install {0} {1}
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={0} env use
sdk.create.custom.hatch.environment=Environment:
sdk.create.custom.hatch.environment.exists=Environment already exists
sdk.create.custom.hatch.error.no.environments.to.select=Hatch didn't provide any environment to select
sdk.create.custom.hatch.error.execution.failed=Please verify Hatch tool, executed with error: {0} {1}
sdk.create.custom.hatch.error.module.is.not.selected=Module is not selected
sdk.create.custom.hatch.error.hatch.executable.path.is.not.valid=Hatch executable path is not valid: {0}
sdk.create.targets.local=Local Machine
sdk.create.custom.virtualenv=Virtualenv
@@ -558,6 +564,7 @@ sdk.create.custom.conda=Conda
sdk.create.custom.pipenv=Pipenv
sdk.create.custom.poetry=Poetry
sdk.create.custom.uv=uv
sdk.create.custom.hatch=Hatch
sdk.create.custom.python=Python
sdk.rendering.detected.grey.text=system

View File

@@ -15,7 +15,6 @@ import com.intellij.ui.dsl.builder.Panel
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.jetbrains.python.PyBundle.message
import com.jetbrains.python.PythonHelpersLocator
import com.jetbrains.python.execution.PyExecutionFailure
import com.jetbrains.python.newProject.collector.InterpreterStatisticsInfo
import com.jetbrains.python.sdk.*
import com.jetbrains.python.sdk.flavors.PythonSdkFlavor
@@ -27,6 +26,7 @@ import com.jetbrains.python.errorProcessing.emit
import kotlinx.coroutines.flow.first
import org.jetbrains.annotations.ApiStatus.Internal
import java.nio.file.Path
import com.jetbrains.python.Result
@Internal
internal abstract class CustomNewEnvironmentCreator(private val name: String, model: PythonMutableTargetAddInterpreterModel) : PythonNewEnvironmentCreator(model) {
@@ -55,7 +55,7 @@ internal abstract class CustomNewEnvironmentCreator(private val name: String, mo
basePythonComboBox.setItems(model.baseInterpreters)
}
override suspend fun getOrCreateSdk(moduleOrProject: ModuleOrProject): com.jetbrains.python.Result<Sdk, PyError> {
override suspend fun getOrCreateSdk(moduleOrProject: ModuleOrProject): Result<Sdk, PyError> {
savePathToExecutableToProperties(null)
// todo think about better error handling
@@ -71,14 +71,13 @@ internal abstract class CustomNewEnvironmentCreator(private val name: String, mo
ProjectJdkTable.getInstance().allJdks.asList(),
model.myProjectPathFlows.projectPathWithDefault.first().toString(),
homePath,
false)
.getOrElse { return com.jetbrains.python.Result.failure(if (it is PyExecutionFailure) PyError.ExecException(it) else PyError.Message(it.localizedMessage)) }
false).getOr { return it }
newSdk.persist()
6
module?.excludeInnerVirtualEnv(newSdk)
model.addInterpreter(newSdk)
return com.jetbrains.python.Result.success(newSdk)
return Result.success(newSdk)
}
override fun createStatisticsInfo(target: PythonInterpreterCreationTargets): InterpreterStatisticsInfo =
@@ -100,7 +99,7 @@ internal abstract class CustomNewEnvironmentCreator(private val name: String, mo
* 5. Reruns `detectExecutable`
*/
@RequiresEdt
private fun createInstallFix(errorSink: ErrorSink): ActionLink {
protected fun createInstallFix(errorSink: ErrorSink): ActionLink {
return ActionLink(message("sdk.create.custom.venv.install.fix.title", name, "via pip")) {
PythonSdkFlavor.clearExecutablesCache()
installExecutable(errorSink)
@@ -154,7 +153,7 @@ internal abstract class CustomNewEnvironmentCreator(private val name: String, mo
*/
internal abstract fun savePathToExecutableToProperties(path: Path?)
protected abstract suspend fun setupEnvSdk(project: Project?, module: Module?, baseSdks: List<Sdk>, projectPath: String, homePath: String?, installPackages: Boolean): Result<Sdk>
protected abstract suspend fun setupEnvSdk(project: Project?, module: Module?, baseSdks: List<Sdk>, projectPath: String, homePath: String?, installPackages: Boolean): Result<Sdk, PyError>
internal abstract suspend fun detectExecutable()
}

View File

@@ -7,11 +7,14 @@ import com.intellij.openapi.observable.properties.ObservableMutableProperty
import com.intellij.openapi.project.Project
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.util.text.nullize
import com.jetbrains.python.errorProcessing.PyError
import com.jetbrains.python.sdk.pipenv.pipEnvPath
import com.jetbrains.python.sdk.pipenv.setupPipEnvSdkUnderProgress
import com.jetbrains.python.statistics.InterpreterType
import java.nio.file.Path
import kotlin.io.path.pathString
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.asPythonResult
internal class EnvironmentCreatorPip(model: PythonMutableTargetAddInterpreterModel) : CustomNewEnvironmentCreator("pipenv", model) {
override val interpreterType: InterpreterType = InterpreterType.PIPENV
@@ -23,8 +26,8 @@ internal class EnvironmentCreatorPip(model: PythonMutableTargetAddInterpreterMod
PropertiesComponent.getInstance().pipEnvPath = savingPath
}
override suspend fun setupEnvSdk(project: Project?, module: Module?, baseSdks: List<Sdk>, projectPath: String, homePath: String?, installPackages: Boolean): Result<Sdk> =
setupPipEnvSdkUnderProgress(project, module, baseSdks, projectPath, homePath, installPackages)
override suspend fun setupEnvSdk(project: Project?, module: Module?, baseSdks: List<Sdk>, projectPath: String, homePath: String?, installPackages: Boolean): Result<Sdk, PyError> =
setupPipEnvSdkUnderProgress(project, module, baseSdks, projectPath, homePath, installPackages).asPythonResult()
override suspend fun detectExecutable() {
model.detectPipEnvExecutable()

View File

@@ -25,6 +25,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import java.nio.file.Path
import kotlin.io.path.pathString
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.PyError
import com.jetbrains.python.errorProcessing.asPythonResult
internal class EnvironmentCreatorPoetry(model: PythonMutableTargetAddInterpreterModel, private val moduleOrProject: ModuleOrProject?) : CustomNewEnvironmentCreator("poetry", model) {
override val interpreterType: InterpreterType = InterpreterType.POETRY
@@ -60,9 +63,9 @@ internal class EnvironmentCreatorPoetry(model: PythonMutableTargetAddInterpreter
PropertiesComponent.getInstance().poetryPath = savingPath
}
override suspend fun setupEnvSdk(project: Project?, module: Module?, baseSdks: List<Sdk>, projectPath: String, homePath: String?, installPackages: Boolean): Result<Sdk> {
override suspend fun setupEnvSdk(project: Project?, module: Module?, baseSdks: List<Sdk>, projectPath: String, homePath: String?, installPackages: Boolean): Result<Sdk, PyError> {
module?.let { service<PoetryConfigService>().setInProjectEnv(it) }
return setupPoetrySdkUnderProgress(project, module, baseSdks, projectPath, homePath, installPackages)
return setupPoetrySdkUnderProgress(project, module, baseSdks, projectPath, homePath, installPackages).asPythonResult()
}
override suspend fun detectExecutable() {

View File

@@ -11,6 +11,9 @@ import com.jetbrains.python.sdk.uv.impl.setUvExecutable
import com.jetbrains.python.sdk.uv.setupUvSdkUnderProgress
import com.jetbrains.python.statistics.InterpreterType
import java.nio.file.Path
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.PyError
import com.jetbrains.python.errorProcessing.asPythonResult
internal class EnvironmentCreatorUv(model: PythonMutableTargetAddInterpreterModel) : CustomNewEnvironmentCreator("uv", model) {
override val interpreterType: InterpreterType = InterpreterType.UV
@@ -27,14 +30,14 @@ internal class EnvironmentCreatorUv(model: PythonMutableTargetAddInterpreterMode
setUvExecutable(savingPath)
}
override suspend fun setupEnvSdk(project: Project?, module: Module?, baseSdks: List<Sdk>, projectPath: String, homePath: String?, installPackages: Boolean): Result<Sdk> {
override suspend fun setupEnvSdk(project: Project?, module: Module?, baseSdks: List<Sdk>, projectPath: String, homePath: String?, installPackages: Boolean): Result<Sdk, PyError> {
if (module == null) {
// FIXME: should not happen, proper error
return Result.failure(Exception("module is null"))
return kotlin.Result.failure<Sdk>(Exception("module is null")).asPythonResult()
}
val python = homePath?.let { Path.of(it) }
return setupUvSdkUnderProgress(ModuleOrProject.ModuleAndProject(module), baseSdks, python)
return setupUvSdkUnderProgress(ModuleOrProject.ModuleAndProject(module), baseSdks, python).asPythonResult()
}
override suspend fun detectExecutable() {

View File

@@ -0,0 +1,76 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.sdk.add.v2
import com.intellij.openapi.observable.properties.ObservableMutableProperty
import com.intellij.openapi.projectRoots.ProjectJdkTable
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.ui.validation.DialogValidationRequestor
import com.intellij.python.hatch.HatchConfiguration
import com.intellij.python.hatch.PythonVirtualEnvironment
import com.intellij.ui.dsl.builder.Panel
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.ErrorSink
import com.jetbrains.python.errorProcessing.PyError
import com.jetbrains.python.newProject.collector.InterpreterStatisticsInfo
import com.jetbrains.python.onSuccess
import com.jetbrains.python.sdk.ModuleOrProject
import com.jetbrains.python.sdk.hatch.createSdk
import com.jetbrains.python.statistics.InterpreterCreationMode
import com.jetbrains.python.statistics.InterpreterType
internal class HatchExistingEnvironmentSelector(
override val model: PythonMutableTargetAddInterpreterModel,
val moduleOrProject: ModuleOrProject,
) : PythonExistingEnvironmentConfigurator(model) {
val interpreterType: InterpreterType = InterpreterType.HATCH
val executable: ObservableMutableProperty<String> = propertyGraph.property(model.state.hatchExecutable.get())
init {
propertyGraph.dependsOn(executable, model.state.hatchExecutable, deleteWhenChildModified = false) {
model.state.hatchExecutable.get()
}
}
override fun buildOptions(panel: Panel, validationRequestor: DialogValidationRequestor, errorSink: ErrorSink) {
panel.buildHatchFormFields(
model = model,
hatchExecutableProperty = executable,
propertyGraph = propertyGraph,
validationRequestor = validationRequestor,
isGenerateNewMode = false,
)
}
override suspend fun getOrCreateSdk(moduleOrProject: ModuleOrProject): Result<Sdk, PyError> {
val existingHatchVenv = state.selectedHatchEnv.get()?.pythonVirtualEnvironment as? PythonVirtualEnvironment.Existing
?: return Result.failure(HatchUIError.HatchEnvironmentIsNotSelected())
val module = (moduleOrProject as? ModuleOrProject.ModuleAndProject)?.module
?: return Result.failure(HatchUIError.ModuleIsNotSelected())
val existingSdk = existingHatchVenv.pythonHomePath.toString().let { venvHomePathString ->
ProjectJdkTable.getInstance().allJdks.find { it.homePath == venvHomePathString }
}
val sdk = when {
existingSdk != null -> Result.success(existingSdk)
else -> existingHatchVenv.createSdk(module)
}.onSuccess {
val executablePath = executable.get().toPath().getOr { return@onSuccess }
HatchConfiguration.persistPathForTarget(hatchExecutablePath = executablePath)
}
return sdk
}
override fun createStatisticsInfo(target: PythonInterpreterCreationTargets): InterpreterStatisticsInfo {
val statisticsTarget = target.toStatisticsField()
return InterpreterStatisticsInfo(
type = InterpreterType.HATCH,
target = statisticsTarget,
globalSitePackage = false,
makeAvailableToAllProjects = false,
previouslyConfigured = true,
isWSLContext = false,
creationMode = InterpreterCreationMode.CUSTOM
)
}
}

View File

@@ -0,0 +1,74 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.sdk.add.v2
import com.intellij.openapi.module.Module
import com.intellij.openapi.observable.properties.ObservableMutableProperty
import com.intellij.openapi.project.Project
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.ui.validation.DialogValidationRequestor
import com.intellij.python.hatch.HatchConfiguration
import com.intellij.python.hatch.getHatchService
import com.intellij.ui.dsl.builder.Panel
import com.intellij.util.text.nullize
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.ErrorSink
import com.jetbrains.python.errorProcessing.PyError
import com.jetbrains.python.onSuccess
import com.jetbrains.python.sdk.hatch.createSdk
import com.jetbrains.python.statistics.InterpreterType
import java.nio.file.Path
internal class HatchNewEnvironmentCreator(
override val model: PythonMutableTargetAddInterpreterModel,
) : CustomNewEnvironmentCreator("hatch", model) {
override val interpreterType: InterpreterType = InterpreterType.HATCH
override val executable: ObservableMutableProperty<String> = propertyGraph.property(model.state.hatchExecutable.get())
init {
propertyGraph.dependsOn(executable, model.state.hatchExecutable, deleteWhenChildModified = false) {
model.state.hatchExecutable.get()
}
}
override val installationVersion: String? = null
override fun buildOptions(panel: Panel, validationRequestor: DialogValidationRequestor, errorSink: ErrorSink) {
panel.buildHatchFormFields(
model = model,
hatchExecutableProperty = executable,
propertyGraph = propertyGraph,
validationRequestor = validationRequestor,
isGenerateNewMode = true,
installHatchActionLink = createInstallFix(errorSink)
) {
basePythonComboBox = it
}
}
override fun savePathToExecutableToProperties(path: Path?) {
val savingPath = path ?: executable.get().nullize()?.let { Path.of(it) } ?: return
HatchConfiguration.persistPathForTarget(hatchExecutablePath = savingPath)
}
override suspend fun setupEnvSdk(project: Project?, module: Module?, baseSdks: List<Sdk>, projectPath: String, homePath: String?, installPackages: Boolean): Result<Sdk, PyError> {
module ?: return Result.failure(HatchUIError.ModuleIsNotSelected())
val selectedEnv = model.state.selectedHatchEnv.get() ?: return Result.failure(HatchUIError.HatchEnvironmentIsNotSelected())
val hatchExecutablePath = executable.get().toPath().getOr { return it }
val hatchService = module.getHatchService(hatchExecutablePath = hatchExecutablePath).getOr { return it }
val existingHatchVenv = hatchService.createVirtualEnvironment(
basePythonBinaryPath = homePath?.let { Path.of(it) },
envName = selectedEnv.hatchEnvironment.name
).getOr { return it }
val createdSdk = existingHatchVenv.createSdk(module).onSuccess {
HatchConfiguration.persistPathForTarget(hatchExecutablePath = hatchExecutablePath)
}
return createdSdk
}
override suspend fun detectExecutable() {
model.detectHatchExecutable()
}
}

View File

@@ -0,0 +1,210 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.sdk.add.v2
import com.intellij.icons.AllIcons
import com.intellij.openapi.observable.properties.ObservableMutableProperty
import com.intellij.openapi.observable.properties.PropertyGraph
import com.intellij.openapi.ui.ComboBox
import com.intellij.openapi.ui.ValidationInfo
import com.intellij.openapi.ui.validation.DialogValidationRequestor
import com.intellij.openapi.ui.validation.WHEN_PROPERTY_CHANGED
import com.intellij.openapi.ui.validation.and
import com.intellij.python.hatch.HatchStandaloneEnvironment
import com.intellij.python.hatch.PythonVirtualEnvironment
import com.intellij.ui.ColoredListCellRenderer
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.ActionLink
import com.intellij.ui.dsl.builder.Align
import com.intellij.ui.dsl.builder.Panel
import com.intellij.ui.dsl.builder.bindItem
import com.intellij.ui.dsl.builder.components.ValidationType
import com.intellij.ui.dsl.builder.components.validationTooltip
import com.intellij.ui.layout.ComponentPredicate
import com.jetbrains.python.PyBundle.message
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.PyError
import com.jetbrains.python.icons.PythonIcons
import com.jetbrains.python.newProjectWizard.collector.PythonNewProjectWizardCollector
import com.jetbrains.python.sdk.add.v2.PythonInterpreterSelectionMethod.SELECT_EXISTING
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.jetbrains.annotations.Nls
import java.nio.file.Path
import javax.swing.JList
internal sealed class HatchUIError(message: String) : PyError.Message(message) {
class ModuleIsNotSelected : HatchUIError(
message("sdk.create.custom.hatch.error.module.is.not.selected")
)
class HatchEnvironmentIsNotSelected : HatchUIError(
message("sdk.create.custom.hatch.error.module.is.not.selected")
)
class HatchExecutablePathIsNotValid(hatchExecutablePath: String?) : HatchUIError(
message("sdk.create.custom.hatch.error.hatch.executable.path.is.not.valid",
hatchExecutablePath)
)
class HatchExecutionFailure(execException: ExecException) : HatchUIError(
message("sdk.create.custom.hatch.error.execution.failed",
execException.execFailure.command, execException.execFailure.args.joinToString(" ")
)
)
}
internal fun String.toPath(): Result<Path, PyError> {
return when (val selectedPath = Path.of(this)) {
null -> Result.failure(HatchUIError.HatchExecutablePathIsNotValid(this))
else -> Result.success(selectedPath)
}
}
private class HatchEnvComboBoxListCellRenderer : ColoredListCellRenderer<HatchStandaloneEnvironment>() {
override fun customizeCellRenderer(list: JList<out HatchStandaloneEnvironment?>, value: HatchStandaloneEnvironment?, index: Int, selected: Boolean, hasFocus: Boolean) {
if (value == null) return
icon = when (value.pythonVirtualEnvironment) {
is PythonVirtualEnvironment.Existing -> PythonIcons.Python.PythonClosed
is PythonVirtualEnvironment.NotExisting -> AllIcons.Nodes.Folder
}
append(value.hatchEnvironment.name, SimpleTextAttributes.REGULAR_ATTRIBUTES)
value.pythonVirtualEnvironment.pythonHomePath?.let { pythonHomePath ->
append("\t", SimpleTextAttributes.REGULAR_ATTRIBUTES)
append(pythonHomePath.toString(), SimpleTextAttributes.GRAYED_SMALL_ATTRIBUTES)
}
}
}
private fun Panel.addEnvironmentComboBox(
model: PythonMutableTargetAddInterpreterModel,
propertyGraph: PropertyGraph,
validationRequestor: DialogValidationRequestor,
isValidateOnlyNotExisting: Boolean,
): ComboBox<HatchStandaloneEnvironment> {
val environmentAlreadyExists = propertyGraph.property(false)
lateinit var environmentComboBox: ComboBox<HatchStandaloneEnvironment>
row(message("sdk.create.custom.hatch.environment")) {
environmentComboBox = comboBox(emptyList(), HatchEnvComboBoxListCellRenderer())
.bindItem(model.state.selectedHatchEnv)
.displayLoaderWhen(model.hatchEnvironmentsLoading, scope = model.scope, uiContext = model.uiContext)
.validationRequestor(validationRequestor and WHEN_PROPERTY_CHANGED(model.state.selectedHatchEnv))
.validationInfo { component ->
environmentAlreadyExists.set(false)
with(component) {
when {
!isVisible -> null
item == null -> ValidationInfo(message("sdk.create.custom.hatch.error.no.environments.to.select"))
isValidateOnlyNotExisting && item?.pythonVirtualEnvironment is PythonVirtualEnvironment.Existing -> {
environmentAlreadyExists.set(true)
ValidationInfo(message("sdk.create.custom.hatch.environment.exists"))
}
else -> null
}
}
}
.align(Align.FILL)
.component
}
row("") {
validationTooltip(
message = message("sdk.create.custom.hatch.environment.exists"),
firstActionLink = ActionLink(message("sdk.create.custom.venv.select.existing.link")) {
PythonNewProjectWizardCollector.logExistingVenvFixUsed()
model.navigator.navigateTo(newMethod = SELECT_EXISTING, newManager = PythonSupportedEnvironmentManagers.HATCH)
},
validationType = ValidationType.ERROR
).align(Align.FILL)
}.visibleIf(environmentAlreadyExists)
return environmentComboBox
}
private fun Panel.addExecutableSelector(
model: PythonMutableTargetAddInterpreterModel,
propertyGraph: PropertyGraph,
hatchExecutableProperty: ObservableMutableProperty<String>,
hatchErrorProperty: ObservableMutableProperty<PyError?>,
validationRequestor: DialogValidationRequestor,
installHatchActionLink: ActionLink? = null,
) {
val hatchErrorMessage = propertyGraph.property<@Nls String>("")
propertyGraph.dependsOn(hatchErrorMessage, hatchErrorProperty, deleteWhenChildModified = false) {
when (val error = hatchErrorProperty.get()) {
null -> ""
is PyError.Message -> error.message
is PyError.ExecException -> HatchUIError.HatchExecutionFailure(error).message
}
}
executableSelector(
hatchExecutableProperty,
validationRequestor,
message("sdk.create.custom.venv.executable.path", "hatch"),
message("sdk.create.custom.venv.missing.text", "hatch"),
installHatchActionLink
).validationOnInput { selector ->
if (!selector.isVisible) return@validationOnInput null
if (hatchExecutableProperty.get() != model.state.hatchExecutable.get()) {
model.state.hatchExecutable.set(hatchExecutableProperty.get())
}
null
}
row("") {
validationTooltip(textProperty = hatchErrorMessage, validationType = ValidationType.ERROR).align(Align.FILL)
}.visibleIf(object : ComponentPredicate() {
override fun addListener(listener: (Boolean) -> Unit) {
hatchErrorProperty.afterChange { listener(invoke()) }
}
override fun invoke(): Boolean = hatchErrorProperty.get() != null
})
}
internal fun Panel.buildHatchFormFields(
model: PythonMutableTargetAddInterpreterModel,
hatchExecutableProperty: ObservableMutableProperty<String>,
propertyGraph: PropertyGraph,
validationRequestor: DialogValidationRequestor,
isGenerateNewMode: Boolean = false,
installHatchActionLink: ActionLink? = null,
basePythonComboboxReceiver: ((PythonInterpreterComboBox) -> Unit) = { },
) {
val environmentComboBox = addEnvironmentComboBox(model, propertyGraph, validationRequestor, isValidateOnlyNotExisting = isGenerateNewMode)
if (isGenerateNewMode) {
row(message("sdk.create.custom.base.python")) {
val basePythonComboBox = pythonInterpreterComboBox(
selectedSdkProperty = model.state.baseInterpreter,
model = model,
onPathSelected = model::addInterpreter,
busyState = model.interpreterLoading
).align(Align.FILL).component
basePythonComboboxReceiver(basePythonComboBox)
}
}
val hatchError = propertyGraph.property<PyError?>(null)
addExecutableSelector(model, propertyGraph, hatchExecutableProperty, hatchError, validationRequestor, installHatchActionLink)
model.hatchEnvironmentsResult.onEach { environmentsResult ->
environmentsResult?.let { environmentComboBox.syncWithEnvs(it, isFilterOnlyExisting = !isGenerateNewMode) }
hatchError.set((environmentsResult as? Result.Failure)?.error)
}.launchIn(model.scope)
}
private fun ComboBox<HatchStandaloneEnvironment>.syncWithEnvs(
environmentsResult: Result<List<HatchStandaloneEnvironment>, PyError>,
isFilterOnlyExisting: Boolean = false,
) {
removeAllItems()
val environments = environmentsResult.getOr { return }
environments.filter { !isFilterOnlyExisting || it.pythonVirtualEnvironment is PythonVirtualEnvironment.Existing }.forEach {
addItem(it)
}
}

View File

@@ -32,13 +32,17 @@ class PythonAddCustomInterpreter(val model: PythonMutableTargetAddInterpreterMod
PIPENV to EnvironmentCreatorPip(model),
POETRY to EnvironmentCreatorPoetry(model, moduleOrProject),
UV to EnvironmentCreatorUv(model),
HATCH to HatchNewEnvironmentCreator(model),
)
private val existingInterpreterSelectors = buildMap {
put(PYTHON, PythonExistingEnvironmentSelector(model))
put(CONDA, CondaExistingEnvironmentSelector(model, errorSink))
if (moduleOrProject != null) put(POETRY, PoetryExistingEnvironmentSelector(model, moduleOrProject))
if (moduleOrProject != null) put(UV, UvExistingEnvironmentSelector(model, moduleOrProject))
if (moduleOrProject != null) {
put(POETRY, PoetryExistingEnvironmentSelector(model, moduleOrProject))
put(UV, UvExistingEnvironmentSelector(model, moduleOrProject))
put(HATCH, HatchExistingEnvironmentSelector(model, moduleOrProject))
}
}
val currentSdkManager: PythonAddEnvironment

View File

@@ -31,6 +31,7 @@ internal class PythonAddLocalInterpreterDialog(private val dialogPresenter: Pyth
init {
title = PyBundle.message("python.sdk.add.python.interpreter.title")
setSize(640, 320)
isResizable = true
init()
}

View File

@@ -28,7 +28,7 @@ import com.jetbrains.python.errorProcessing.ErrorSink
import com.jetbrains.python.errorProcessing.PyError
import kotlinx.coroutines.CoroutineScope
import javax.swing.Icon
import com.intellij.python.hatch.icons.PythonHatchIcons
@Service(Service.Level.APP)
internal class PythonAddSdkService(val coroutineScope: CoroutineScope)
@@ -63,6 +63,7 @@ enum class PythonSupportedEnvironmentManagers(val nameKey: String, val icon: Ico
POETRY("sdk.create.custom.poetry", POETRY_ICON),
PIPENV("sdk.create.custom.pipenv", PIPENV_ICON),
UV("sdk.create.custom.uv", UV_ICON),
HATCH("sdk.create.custom.hatch", PythonHatchIcons.Logo),
PYTHON("sdk.create.custom.python", com.jetbrains.python.psi.icons.PythonPsiApiIcons.Python)
}

View File

@@ -10,16 +10,23 @@ import com.intellij.openapi.observable.properties.ObservableMutableProperty
import com.intellij.openapi.observable.properties.PropertyGraph
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.util.NlsSafe
import com.intellij.openapi.util.io.NioFiles
import com.intellij.python.community.services.internal.impl.PythonWithLanguageLevelImpl
import com.intellij.python.community.services.shared.PythonWithLanguageLevel
import com.intellij.python.community.services.systemPython.SystemPython
import com.intellij.python.community.services.systemPython.SystemPythonService
import com.intellij.python.community.services.systemPython.UICustomization
import com.intellij.python.hatch.HatchConfiguration
import com.intellij.python.hatch.HatchStandaloneEnvironment
import com.intellij.python.hatch.getHatchService
import com.jetbrains.python.PyBundle.message
import com.jetbrains.python.configuration.PyConfigurableInterpreterList
import com.jetbrains.python.errorProcessing.ErrorSink
import com.jetbrains.python.errorProcessing.PyError
import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.failure
import com.jetbrains.python.getOrNull
import com.jetbrains.python.isFailure
import com.jetbrains.python.newProject.steps.ProjectSpecificSettingsStep
import com.jetbrains.python.newProjectWizard.projectPath.ProjectPathFlows
import com.jetbrains.python.psi.LanguageLevel
@@ -33,17 +40,14 @@ import com.jetbrains.python.sdk.pipenv.getPipEnvExecutable
import com.jetbrains.python.sdk.poetry.getPoetryExecutable
import com.jetbrains.python.sdk.uv.impl.getUvExecutable
import com.jetbrains.python.venvReader.tryResolvePath
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import java.nio.file.InvalidPathException
import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.io.path.isDirectory
import kotlin.io.path.pathString
@OptIn(ExperimentalCoroutinesApi::class)
abstract class PythonAddInterpreterModel(params: PyInterpreterModelParams, private val systemPythonService: SystemPythonService = SystemPythonService()) {
@@ -64,6 +68,7 @@ abstract class PythonAddInterpreterModel(params: PyInterpreterModelParams, priva
val manuallyAddedInterpreters: MutableStateFlow<List<PythonSelectableInterpreter>> = MutableStateFlow(emptyList())
private var installable: List<PythonSelectableInterpreter> = emptyList()
val condaEnvironments: MutableStateFlow<List<PyCondaEnv>> = MutableStateFlow(emptyList())
val hatchEnvironmentsResult: MutableStateFlow<com.jetbrains.python.Result<List<HatchStandaloneEnvironment>, PyError>?> = MutableStateFlow(null)
var allInterpreters: StateFlow<List<PythonSelectableInterpreter>> = combine(knownInterpreters,
detectedInterpreters,
@@ -81,6 +86,7 @@ abstract class PythonAddInterpreterModel(params: PyInterpreterModelParams, priva
val interpreterLoading = MutableStateFlow(false)
val condaEnvironmentsLoading = MutableStateFlow(false)
val hatchEnvironmentsLoading: MutableStateFlow<Boolean> = MutableStateFlow(true)
open fun createBrowseAction(): () -> String? = TODO()
@@ -125,6 +131,33 @@ abstract class PythonAddInterpreterModel(params: PyInterpreterModelParams, priva
return@withContext null
}
suspend fun detectHatchEnvironments(
hatchExecutablePathString: String,
): com.jetbrains.python.Result<List<HatchStandaloneEnvironment>, PyError> {
hatchEnvironmentsLoading.value = true
val environmentsResult = withContext(Dispatchers.IO) {
val projectPath = myProjectPathFlows.projectPathWithDefault.first()
val hatchExecutablePath = NioFiles.toPath(hatchExecutablePathString)
?: return@withContext com.jetbrains.python.Result.failure(
HatchUIError.HatchExecutablePathIsNotValid(hatchExecutablePathString)
)
val hatchWorkingDirectory = if (projectPath.isDirectory()) projectPath else projectPath.parent
val hatchService = getHatchService(
workingDirectoryPath = hatchWorkingDirectory,
hatchExecutablePath = hatchExecutablePath,
).getOr { return@withContext it }
val hatchEnvironments = hatchService.findStandaloneEnvironments().getOr { return@withContext it }
val availableEnvironments = when {
hatchWorkingDirectory == projectPath -> hatchEnvironments
else -> HatchStandaloneEnvironment.AVAILABLE_ENVIRONMENTS_FOR_NEW_PROJECT
}
com.jetbrains.python.Result.success(availableEnvironments)
}
return environmentsResult
}
private suspend fun initInterpreterList() {
withContext(Dispatchers.IO) {
val existingSdks = PyConfigurableInterpreterList.getInstance(null).getModel().sdks.toList()
@@ -215,11 +248,30 @@ abstract class PythonMutableTargetAddInterpreterModel(params: PyInterpreterModel
: PythonAddInterpreterModel(params) {
override val state: MutableTargetState = MutableTargetState(propertyGraph)
init {
state.hatchExecutable.afterChange { pathString ->
scope.launch {
hatchEnvironmentsLoading.value = true
val hatchEnvironmentResult = detectHatchEnvironments(pathString)
withContext(uiContext) {
hatchEnvironmentsResult.value = hatchEnvironmentResult
if (hatchEnvironmentResult.isFailure) {
state.selectedHatchEnv.set(null)
}
else {
hatchEnvironmentsLoading.value = false
}
}
}
}
}
override suspend fun initialize() {
super.initialize()
detectPoetryExecutable()
detectPipEnvExecutable()
detectUvExecutable()
detectHatchExecutable()
}
suspend fun detectPoetryExecutable() {
@@ -245,6 +297,15 @@ abstract class PythonMutableTargetAddInterpreterModel(params: PyInterpreterModel
}
}
}
suspend fun detectHatchExecutable() {
HatchConfiguration.getOrDetectHatchExecutablePath().getOrNull()?.pathString?.let {
withContext(Dispatchers.EDT) {
hatchEnvironmentsLoading.value = true
state.hatchExecutable.set(it)
}
}
}
}
class PythonLocalAddInterpreterModel(params: PyInterpreterModelParams)
@@ -337,6 +398,8 @@ open class AddInterpreterState(propertyGraph: PropertyGraph) {
* Use [PythonAddInterpreterModel.getBaseCondaOrError]
*/
val baseCondaEnv: ObservableMutableProperty<PyCondaEnv?> = propertyGraph.property(null)
val selectedHatchEnv: ObservableMutableProperty<HatchStandaloneEnvironment?> = propertyGraph.property(null)
}
class MutableTargetState(propertyGraph: PropertyGraph) : AddInterpreterState(propertyGraph) {
@@ -344,6 +407,7 @@ class MutableTargetState(propertyGraph: PropertyGraph) : AddInterpreterState(pro
val newCondaEnvName: ObservableMutableProperty<String> = propertyGraph.property("")
val poetryExecutable: ObservableMutableProperty<String> = propertyGraph.property("")
val uvExecutable: ObservableMutableProperty<String> = propertyGraph.property("")
val hatchExecutable: ObservableMutableProperty<String> = propertyGraph.property("")
val pipenvExecutable: ObservableMutableProperty<String> = propertyGraph.property("")
val venvPath: ObservableMutableProperty<String> = propertyGraph.property("")
val inheritSitePackages = propertyGraph.property(false)

View File

@@ -0,0 +1,36 @@
// 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.hatch
import com.intellij.python.hatch.icons.PythonHatchIcons
import com.jetbrains.python.sdk.PythonSdkAdditionalData
import com.jetbrains.python.sdk.flavors.*
import org.jdom.Element
import javax.swing.Icon
typealias HatchSdkFlavorData = PyFlavorData.Empty
object HatchSdkFlavor : CPythonSdkFlavor<HatchSdkFlavorData>() {
override fun getIcon(): Icon = PythonHatchIcons.Logo
override fun getFlavorDataClass(): Class<HatchSdkFlavorData> = HatchSdkFlavorData::class.java
override fun isValidSdkPath(pathStr: String): Boolean = false
}
class HatchSdkFlavorProvider : PythonFlavorProvider {
override fun getFlavor(platformIndependent: Boolean): PythonSdkFlavor<*> = HatchSdkFlavor
}
class HatchSdkAdditionalData(data: PythonSdkAdditionalData) : PythonSdkAdditionalData(data) {
constructor() : this(
data = PythonSdkAdditionalData(PyFlavorAndData(data = HatchSdkFlavorData, flavor = HatchSdkFlavor))
)
override fun save(element: Element) {
super.save(element)
element.setAttribute(IS_HATCH, "true")
}
companion object {
private const val IS_HATCH = "IS_HATCH"
}
}

View File

@@ -0,0 +1,47 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.sdk.hatch
import com.intellij.openapi.module.Module
import com.intellij.openapi.projectRoots.ProjectJdkTable
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.util.NlsSafe
import com.intellij.python.hatch.EnvironmentCreationHatchError
import com.intellij.python.hatch.PythonVirtualEnvironment
import com.intellij.python.hatch.getHatchEnvVirtualProjectPath
import com.jetbrains.python.Result
import com.jetbrains.python.errorProcessing.PyError
import com.jetbrains.python.errorProcessing.failure
import com.jetbrains.python.resolvePythonBinary
import com.jetbrains.python.sdk.basePath
import com.jetbrains.python.sdk.createSdk
import com.jetbrains.python.sdk.setAssociationToPath
import org.jetbrains.annotations.ApiStatus
import kotlin.io.path.name
@ApiStatus.Internal
suspend fun PythonVirtualEnvironment.Existing.createSdk(module: Module): Result<Sdk, PyError> {
val pythonBinary = pythonHomePath.resolvePythonBinary()
?: return failure("Cannot find Python Binary")
val sdk = createSdk(
sdkHomePath = pythonBinary,
existingSdks = ProjectJdkTable.getInstance().allJdks.asList(),
associatedProjectPath = module.project.basePath,
suggestedSdkName = suggestHatchSdkName(),
sdkAdditionalData = HatchSdkAdditionalData()
).getOrElse { exception ->
return Result.failure(EnvironmentCreationHatchError(exception.localizedMessage))
}.also {
it.setAssociationToPath(module.basePath.toString())
}
return Result.success(sdk)
}
private fun PythonVirtualEnvironment.Existing.suggestHatchSdkName(): @NlsSafe String {
val normalizedProjectName = pythonHomePath.getHatchEnvVirtualProjectPath().name
val nonDefaultEnvName = pythonHomePath.name.takeIf { it != normalizedProjectName }
val envNamePrefix = nonDefaultEnvName?.let { "$it@" } ?: ""
val sdkName = "Hatch ($envNamePrefix$normalizedProjectName) [$pythonVersion]"
return sdkName
}

View File

@@ -126,6 +126,7 @@ enum class InterpreterType(val value: String) {
POETRY("poetry"),
PYENV("pyenv"),
UV("uv"),
HATCH("hatch"),
}
enum class InterpreterCreationMode(val value: String) {