diff --git a/python/python-hatch/gen/com/intellij/python/hatch/icons/PythonHatchIcons.java b/python/python-hatch/gen/com/intellij/python/hatch/icons/PythonHatchIcons.java index ed607c9a5231..0bf9955c97ba 100644 --- a/python/python-hatch/gen/com/intellij/python/hatch/icons/PythonHatchIcons.java +++ b/python/python-hatch/gen/com/intellij/python/hatch/icons/PythonHatchIcons.java @@ -14,5 +14,5 @@ public final class PythonHatchIcons { private static @NotNull Icon load(@NotNull String path, int cacheKey, int flags) { return IconManager.getInstance().loadRasterizedIcon(path, PythonHatchIcons.class.getClassLoader(), cacheKey, flags); } - /** 16x16 */ public static final @NotNull Icon Logo = load("icons/com/intellij/python/hatch/expui/logo.svg", 1803583895, 0); + /** 16x16 */ public static final @NotNull Icon Logo = load("icons/com/intellij/python/hatch/expui/logo.svg", 1919219635, 0); } diff --git a/python/python-hatch/intellij.python.hatch.iml b/python/python-hatch/intellij.python.hatch.iml index 216c0189a567..bffcd7841394 100644 --- a/python/python-hatch/intellij.python.hatch.iml +++ b/python/python-hatch/intellij.python.hatch.iml @@ -41,5 +41,7 @@ + + \ No newline at end of file diff --git a/python/python-hatch/resources/icons/com/intellij/python/hatch/expui/logo.svg b/python/python-hatch/resources/icons/com/intellij/python/hatch/expui/logo.svg index a1add0e6da4c..74982ecc3671 100644 --- a/python/python-hatch/resources/icons/com/intellij/python/hatch/expui/logo.svg +++ b/python/python-hatch/resources/icons/com/intellij/python/hatch/expui/logo.svg @@ -1,74 +1,77 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - + - + - + - + - + - + - + - + - + diff --git a/python/python-hatch/resources/messages/PyHatchBundle.properties b/python/python-hatch/resources/messages/PyHatchBundle.properties index d19366e02aad..69398062d78c 100644 --- a/python/python-hatch/resources/messages/PyHatchBundle.properties +++ b/python/python-hatch/resources/messages/PyHatchBundle.properties @@ -1,2 +1,6 @@ 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.working.directory.is.not.found=Working directory is not found at: {0} +python.hatch.error.environment.creation=Environment creation failure: {0} +python.hatch.error.filesystem.operation=The problem with the filesystem operation: {0} python.hatch.cli.error.response.out.of.pattern=Hatch command responded with non-expected output.\nExpected: {0} diff --git a/python/python-hatch/src/com/intellij/python/hatch/HatchConfiguration.kt b/python/python-hatch/src/com/intellij/python/hatch/HatchConfiguration.kt index a0882b27bc6c..0f88b6e8634e 100644 --- a/python/python-hatch/src/com/intellij/python/hatch/HatchConfiguration.kt +++ b/python/python-hatch/src/com/intellij/python/hatch/HatchConfiguration.kt @@ -4,7 +4,9 @@ import com.intellij.ide.util.PropertiesComponent import com.intellij.platform.eel.EelApi import com.intellij.platform.eel.LocalEelApi import com.intellij.platform.eel.provider.asNioPath +import com.intellij.platform.eel.provider.localEel import com.intellij.platform.eel.provider.utils.where +import com.intellij.python.hatch.runtime.getHatchCommand import com.jetbrains.python.Result import java.nio.file.Path import kotlin.io.path.isExecutable @@ -16,22 +18,22 @@ object HatchConfiguration { get() = PropertiesComponent.getInstance().getValue(PYCHARM_HATCH_LOCAL_EXECUTABLE_PATH_SETTING)?.let { Path.of(it) } set(value) = PropertiesComponent.getInstance().setValue(PYCHARM_HATCH_LOCAL_EXECUTABLE_PATH_SETTING, value?.toString()) - fun getPersistedPathForTarget(eelApi: EelApi): Path? = when { + fun getPersistedPathForTarget(eelApi: EelApi = localEel): Path? = when { eelApi is LocalEelApi -> localHatchExecutablePath else -> null } - fun persistPathForTarget(eelApi: EelApi, hatchExecutablePath: Path?) { + fun persistPathForTarget(eelApi: EelApi = localEel, hatchExecutablePath: Path?) { if (eelApi is LocalEelApi) localHatchExecutablePath = hatchExecutablePath } - suspend fun getOrDetectHatchExecutablePath(eelApi: EelApi): Result { + suspend fun getOrDetectHatchExecutablePath(eelApi: EelApi = localEel): Result { val path = getPersistedPathForTarget(eelApi) ?: run { detectHatchExecutable(eelApi)?.also { persistPathForTarget(eelApi, it) } } val result = when { - path?.isExecutable() != true -> Result.failure(ExecutableNotFoundHatchError(path)) + path?.isExecutable() != true -> Result.failure(HatchExecutableNotFoundHatchError(path)) else -> Result.success(path) } diff --git a/python/python-hatch/src/com/intellij/python/hatch/HatchRuntime.kt b/python/python-hatch/src/com/intellij/python/hatch/HatchRuntime.kt deleted file mode 100644 index 43b36e87d55d..000000000000 --- a/python/python-hatch/src/com/intellij/python/hatch/HatchRuntime.kt +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright 2000-2025 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.community.execService.ExecOptions -import com.intellij.python.community.execService.ExecService -import com.intellij.python.community.execService.WhatToExec -import com.intellij.python.community.execService.ProcessOutputTransformer -import com.intellij.python.hatch.cli.HatchCli -import com.jetbrains.python.Result -import com.jetbrains.python.errorProcessing.PyError.ExecException -import java.nio.file.Path - - -class HatchRuntime( - val hatchBinary: WhatToExec, - val execOptions: ExecOptions, - private val execService: ExecService = ExecService(), -) { - fun hatchCli(): HatchCli = HatchCli(this) - - fun withWorkingDirectory(workDirectoryPath: Path): HatchRuntime { - val execOptions = with(this.execOptions) { - ExecOptions(env, workDirectoryPath, processDescription, timeout) - } - return HatchRuntime(this.hatchBinary, execOptions) - } - - fun withBasePythonPath(basePythonPath: Path): HatchRuntime { - val execOptions = with(this.execOptions) { - val modifiedEnv = env + mapOf( - HatchConstants.AppEnvVars.PYTHON to basePythonPath.toString() - ) - ExecOptions(modifiedEnv, workingDirectory, processDescription, timeout) - } - return HatchRuntime(this.hatchBinary, execOptions) - } - - /** - * Pure execution of [hatchBinary] with command line [arguments] and [execOptions] by [execService] - * Doesn't make any validation of stdout/stderr content. - */ - internal suspend fun execute(vararg arguments: String, processOutputTransformer: ProcessOutputTransformer): Result { - return execService.execute(hatchBinary, arguments.toList(), execOptions, processOutputTransformer) - } -} - diff --git a/python/python-hatch/src/com/intellij/python/hatch/cli/HatchCli.kt b/python/python-hatch/src/com/intellij/python/hatch/cli/HatchCli.kt index c04add6e3450..55d361523c95 100644 --- a/python/python-hatch/src/com/intellij/python/hatch/cli/HatchCli.kt +++ b/python/python-hatch/src/com/intellij/python/hatch/cli/HatchCli.kt @@ -3,9 +3,9 @@ package com.intellij.python.hatch.cli import com.intellij.execution.process.ProcessOutput import com.intellij.openapi.util.NlsSafe +import com.intellij.platform.eel.provider.utils.sendWholeText import com.intellij.python.community.execService.ProcessOutputTransformer -import com.intellij.python.community.execService.ZeroCodeStdoutTransformer -import com.intellij.python.hatch.HatchRuntime +import com.intellij.python.hatch.runtime.HatchRuntime import com.intellij.python.hatch.PyHatchBundle import com.jetbrains.python.Result import com.jetbrains.python.errorProcessing.PyError @@ -110,9 +110,21 @@ class HatchCli(private val runtime: HatchRuntime) { * ├── LICENSE.txt * ├── README.md * └── pyproject.toml + * + * @param[initExistingProject] Initialize an existing project */ - suspend fun new(projectName: String): Result { - return runtime.executeAndHandleErrors("new", projectName, transformer = ZeroCodeStdoutTransformer) + suspend fun new(projectName: String, location: Path? = null, initExistingProject: Boolean = false): Result { + val options = listOf( + initExistingProject to "--init", + true to projectName, + (location != null) to location, + ).makeOptions() + return runtime.executeInteractive("new", *options) { eelProcess -> + if (initExistingProject) { + eelProcess.stdin.sendWholeText("$projectName\n") + } + Result.success("Created") + } } /** @@ -208,12 +220,11 @@ class HatchCli(private val runtime: HatchRuntime) { } -internal fun List>.makeOptions(): Array { +internal fun List>.makeOptions(): Array { return this.mapNotNull { (flag, option) -> when (flag) { - true -> option - false -> "$option=0" - null -> null + true -> option.toString() + else -> null } }.toTypedArray() } \ No newline at end of file diff --git a/python/python-hatch/src/com/intellij/python/hatch/cli/HatchConfig.kt b/python/python-hatch/src/com/intellij/python/hatch/cli/HatchConfig.kt index 3842988a0f19..6b5a619b1ff6 100644 --- a/python/python-hatch/src/com/intellij/python/hatch/cli/HatchConfig.kt +++ b/python/python-hatch/src/com/intellij/python/hatch/cli/HatchConfig.kt @@ -2,7 +2,7 @@ package com.intellij.python.hatch.cli import com.intellij.python.community.execService.ZeroCodeStdoutTransformer -import com.intellij.python.hatch.HatchRuntime +import com.intellij.python.hatch.runtime.HatchRuntime import com.jetbrains.python.Result import com.jetbrains.python.errorProcessing.PyError.ExecException diff --git a/python/python-hatch/src/com/intellij/python/hatch/cli/HatchDep.kt b/python/python-hatch/src/com/intellij/python/hatch/cli/HatchDep.kt index 5dc4b2caf2ca..dceef2e26d64 100644 --- a/python/python-hatch/src/com/intellij/python/hatch/cli/HatchDep.kt +++ b/python/python-hatch/src/com/intellij/python/hatch/cli/HatchDep.kt @@ -2,7 +2,7 @@ package com.intellij.python.hatch.cli import com.intellij.python.community.execService.ZeroCodeStdoutTransformer -import com.intellij.python.hatch.HatchRuntime +import com.intellij.python.hatch.runtime.HatchRuntime import com.jetbrains.python.Result import com.jetbrains.python.errorProcessing.PyError.ExecException diff --git a/python/python-hatch/src/com/intellij/python/hatch/cli/HatchEnv.kt b/python/python-hatch/src/com/intellij/python/hatch/cli/HatchEnv.kt index e314dd665660..ee9f82524954 100644 --- a/python/python-hatch/src/com/intellij/python/hatch/cli/HatchEnv.kt +++ b/python/python-hatch/src/com/intellij/python/hatch/cli/HatchEnv.kt @@ -1,7 +1,9 @@ // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.python.hatch.cli -import com.intellij.python.hatch.HatchRuntime +import com.intellij.openapi.util.NlsSafe +import com.intellij.python.hatch.runtime.HatchRuntime +import com.jetbrains.python.PythonHomePath import com.jetbrains.python.Result import com.jetbrains.python.errorProcessing.PyError.ExecException import kotlinx.serialization.SerialName @@ -13,22 +15,22 @@ import java.nio.file.Path @Suppress("unused") @Serializable -class Environment( +class HatchEnvironmentDetails( /** * An environment's type determines which environment plugin will be used for management. * The only built-in environment type is virtual, which uses virtual Python environments. */ - val type: String, + val type: @NlsSafe String, /** * The python option specifies which version of Python to use, or an absolute path to a Python interpreter: */ - val python: String? = null, + val python: @NlsSafe String? = null, /** * The description option is purely informational and is displayed in the output of the env show command: */ - val description: String? = null, + val description: @NlsSafe String? = null, /** * A common use case is standalone environments that do not require inheritance nor the installation of the project, @@ -40,7 +42,7 @@ class Environment( /** * All environments inherit from the environment defined by its template option, which defaults to default. */ - val template: String? = null, + val template: @NlsSafe String? = null, val installer: String? = null, @@ -101,9 +103,10 @@ class Environment( val envExclude: List? = null, ) -typealias Environments = Map +typealias HatchDetailedEnvironments = Map -private const val DEFAULT_ENV_NAME: String = "default" +const val DEFAULT_ENV_NAME: String = "default" +const val ENV_TYPE_VIRTUAL: String = "virtual" /** * Manage project environments @@ -138,7 +141,7 @@ class HatchEnv(runtime: HatchRuntime) : HatchCommand("env", runtime) { * * @return path to environment */ - suspend fun find(envName: String? = null): Result { + suspend fun find(envName: String? = null): Result { val arguments = if (envName == null) emptyArray() else arrayOf(envName) return executeAndHandleErrors("find", *arguments) { when (it.exitCode) { @@ -191,29 +194,125 @@ class HatchEnv(runtime: HatchRuntime) : HatchCommand("env", runtime) { * Returns details of the specified environments. * * @param envs A vararg parameter specifying the environment names to be displayed. If not provided, information for all environments is shown. - * @param internal Optional parameter indicating whether to include internal environments. Defaults to null. * @return A [Result] containing: - * - [Environments] if operation is successful. + * - [HatchDetailedEnvironments] if operation is successful. * - An error wrapped in [ExecException] if an execution failure occurs. */ - suspend fun show(vararg envs: String, internal: Boolean? = null): Result { - val options = listOf(internal to "--internal").makeOptions() - return executeAndHandleErrors("show", "--json", *options, *envs) { processOutput -> + suspend fun showWithDetails(vararg envs: String): Result { + return executeAndHandleErrors("show", "--json", *envs) { processOutput -> val output = processOutput.takeIf { it.exitCode == 0 }?.stdout ?: return@executeAndHandleErrors Result.failure(null) val json = Json { ignoreUnknownKeys = true } val jsonOutput = json.parseToJsonElement(output) - val environments = if (internal == true) jsonOutput.jsonObject - else { - // JSON mode always shows internal environments, and there is no flag to distinguish them - jsonOutput.jsonObject.filterKeys { !it.startsWith("hatch-") } - } + + // JSON mode always shows internal environments, and there is no flag to distinguish them + val environments = jsonOutput.jsonObject.filterKeys { !it.startsWith("hatch-") } val parsedEnvironments = environments.mapValues { - json.decodeFromJsonElement(it.value) + json.decodeFromJsonElement(it.value) } Result.success(parsedEnvironments) } } + + /** + * Returns details of the specified environments. + * + * @param envs A vararg parameter specifying the environment names to be displayed. If not provided, information for all environments is shown. + * @param internal Optional parameter indicating whether to include internal environments. Defaults to false. + * @return A [Result] containing: + * - [HatchDetailedEnvironments] if operation is successful. + * - An error wrapped in [ExecException] if an execution failure occurs. + */ + suspend fun show(vararg envs: String, internal: Boolean = false): Result { + val options = listOf(internal to "--internal").makeOptions() + + val expectedOutput = """^\s+Standalone\s*\n((?:[+|].*[+|]\n)+)(?:\s+Matrices\s*\n((?:[+|].*[+|]\n)+))?$""".toRegex() + + return executeAndMatch("show", "--ascii", *options, *envs, expectedOutput = expectedOutput) { matchResult -> + val tables = buildMap { + val (standaloneTable, matricesTable) = matchResult.destructured + put(HatchEnvironmentType.STANDALONE, standaloneTable.parseHatchEnvironments()) + put(HatchEnvironmentType.MATRICES, matricesTable.parseHatchEnvironments()) + } + Result.success(tables) + } + } +} + +typealias HatchEnvironments = Map> + +enum class HatchEnvironmentType { + STANDALONE, + MATRICES, +} + +data class HatchEnvironment( + val name: @NlsSafe String, + val type: @NlsSafe String, + val envs: String? = null, + val features: String? = null, + val dependencies: String? = null, + val environmentVariables: String? = null, + val scripts: String? = null, + val description: String? = null, +) { + companion object { + val DEFAULT: HatchEnvironment = HatchEnvironment(name = DEFAULT_ENV_NAME, type = ENV_TYPE_VIRTUAL) + } +} + +private fun String.parseHatchEnvironments(): List { + val table = parseTable() ?: return emptyList() + val nameIdx = table.findColumnIdx("Name") ?: error("Name column not found") + val typeIdx = table.findColumnIdx("Type") ?: error("Type column not found") + val envIdx = table.findColumnIdx("Env") + val featuresIdx = table.findColumnIdx("Features") + val dependenciesIdx = table.findColumnIdx("Features") + val environmentVariablesIdx = table.findColumnIdx("EnvironmentVariables") + val scriptsIdx = table.findColumnIdx("Scripts") + val descriptionIdx = table.findColumnIdx("Description") + + return table.rows.map { row -> + HatchEnvironment( + name = row[nameIdx], + type = row[typeIdx], + envs = envIdx?.let { row[it] }, + features = featuresIdx?.let { row[it] }, + dependencies = dependenciesIdx?.let { row[it] }, + environmentVariables = environmentVariablesIdx?.let { row[it] }, + scripts = scriptsIdx?.let { row[it] }, + description = descriptionIdx?.let { row[it] }, + ) + } +} + + +private data class Table(val headers: List, val rows: List>) + +private fun Table.findColumnIdx(name: String): Int? = headers.indexOf(name).takeIf { it >= 0 } + + +private fun String.parseTable(): Table? { + val lines = this.trim().lines() + val columns = lines.first().count { it == '+' } - 1 + if (columns <= 0) return null + + val data = buildList { + val currentRow = Array(columns) { "" } + for (line in lines.drop(1)) { + if (line.startsWith('+')) { + add(currentRow.map { it.trim() }) + currentRow.fill("") + continue + } + + val cells = line.splitToSequence('|').map { it.trim() }.toList() + for (col in 0.. "hatch.exe" - else -> "hatch" +class BasePythonExecutableNotFoundHatchError(pathString: String?) : HatchError( + PyHatchBundle.message("python.hatch.error.base.python.executable.is.not.found", pathString.toString()) +) { + constructor(path: Path) : this(path.toString()) } -suspend fun createHatchRuntime( - workingDirectory: Path, - envVars: Map = emptyMap(), - eelApi: EelApi = localEel, -): Result { - val hatchExecutable = HatchConfiguration.getOrDetectHatchExecutablePath(eelApi).getOr { return it } +class WorkingDirectoryNotFoundHatchError(pathString: String?) : HatchError( + PyHatchBundle.message("python.hatch.error.working.directory.is.not.found", pathString.toString()) +) { + constructor(path: Path?) : this(path.toString()) +} - val defaultVariables = mapOf( - HatchConstants.AppEnvVars.NO_COLOR to "1", - HatchConstants.AppEnvVars.VERBOSE to "1", - HatchConstants.AppEnvVars.INTERACTIVE to "0", - "TERM" to "dumb", - "COLUMNS" to "${0x7FFF}", - "LINES" to "${0x7FFF}", - ) - val actualEnvVars = defaultVariables + envVars +class EnvironmentCreationHatchError(details: @NlsSafe String) : HatchError( + PyHatchBundle.message("python.hatch.error.environment.creation", details) +) - val runtime = HatchRuntime( - hatchBinary = WhatToExec.Binary(hatchExecutable), - execOptions = ExecOptions( - env = actualEnvVars, - workingDirectory = workingDirectory +class FileSystemOperationHatchError(eelFsError: EelFsError) : HatchError( + PyHatchBundle.message("python.hatch.error.filesystem.operation", eelFsError) +) + + +data class HatchStandaloneEnvironment( + val hatchEnvironment: HatchEnvironment, + val pythonVirtualEnvironment: PythonVirtualEnvironment, +) { + companion object { + val AVAILABLE_ENVIRONMENTS_FOR_NEW_PROJECT: List = listOf( + HatchStandaloneEnvironment(HatchEnvironment.DEFAULT, PythonVirtualEnvironment.NotExisting()) ) - ) - return Result.success(runtime) + } } +sealed interface PythonVirtualEnvironment { + val pythonHomePath: PythonHomePath? + + data class Existing(override val pythonHomePath: PythonHomePath, val pythonVersion: String) : PythonVirtualEnvironment + data class NotExisting(override val pythonHomePath: PythonHomePath? = null) : PythonVirtualEnvironment +} + +interface HatchService { + fun getWorkingDirectoryPath(): Path + + @RequiresBackgroundThread + suspend fun isHatchManagedProject(): Result + + @RequiresBackgroundThread + suspend fun createNewProject(projectName: String): Result + + /** + * param[basePythonBinaryPath] base python for environment, the one on the PATH should be used if null. + * param[envName] environment name to create, 'default' should be used if null. + */ + @RequiresBackgroundThread + suspend fun createVirtualEnvironment(basePythonBinaryPath: PythonBinary? = null, envName: String? = null): Result + + @RequiresBackgroundThread + suspend fun findStandaloneEnvironments(): Result, PyError> +} + +/** + * Hatch Service for working directory (where hatch.toml / pyproject.toml is usually placed) + */ +@RequiresBackgroundThread +suspend fun getHatchService(workingDirectoryPath: Path, hatchExecutablePath: Path? = null): Result { + return CliBasedHatchService(hatchExecutablePath = hatchExecutablePath, workingDirectoryPath = workingDirectoryPath) +} + +/** + * Hatch Service for Module. + * Working directory considered as the module base path. + */ +@RequiresBackgroundThread +suspend fun Module.getHatchService(hatchExecutablePath: Path? = null): Result { + val workingDirectoryPath = basePath?.let { Path.of(it) } + ?: return Result.failure(WorkingDirectoryNotFoundHatchError(basePath)) + return getHatchService(workingDirectoryPath = workingDirectoryPath, hatchExecutablePath = hatchExecutablePath) +} + +/** + * ../hatch/env/virtual/{normalized-project-name}/{hash}/{python-home} + */ +fun PythonHomePath.getHatchEnvVirtualProjectPath(): Path = this.parent.parent diff --git a/python/python-hatch/src/com/intellij/python/hatch/HatchConstants.kt b/python/python-hatch/src/com/intellij/python/hatch/runtime/HatchConstants.kt similarity index 97% rename from python/python-hatch/src/com/intellij/python/hatch/HatchConstants.kt rename to python/python-hatch/src/com/intellij/python/hatch/runtime/HatchConstants.kt index bd67260c6ca5..7dc337586566 100644 --- a/python/python-hatch/src/com/intellij/python/hatch/HatchConstants.kt +++ b/python/python-hatch/src/com/intellij/python/hatch/runtime/HatchConstants.kt @@ -1,4 +1,4 @@ -package com.intellij.python.hatch +package com.intellij.python.hatch.runtime @Suppress("unused") object HatchConstants { @@ -41,4 +41,4 @@ object HatchConstants { object VersionEnvVars { const val VALIDATE_BUMP: String = "HATCH_VERSION_VALIDATE_BUMP" } -} +} \ No newline at end of file diff --git a/python/python-hatch/src/com/intellij/python/hatch/runtime/HatchRuntime.kt b/python/python-hatch/src/com/intellij/python/hatch/runtime/HatchRuntime.kt new file mode 100644 index 000000000000..6bd65ee8e298 --- /dev/null +++ b/python/python-hatch/src/com/intellij/python/hatch/runtime/HatchRuntime.kt @@ -0,0 +1,94 @@ +package com.intellij.python.hatch.runtime + +import com.intellij.platform.eel.EelApi +import com.intellij.platform.eel.provider.localEel +import com.intellij.python.community.execService.* +import com.intellij.python.hatch.BasePythonExecutableNotFoundHatchError +import com.intellij.python.hatch.HatchConfiguration +import com.intellij.python.hatch.HatchError +import com.intellij.python.hatch.WorkingDirectoryNotFoundHatchError +import com.intellij.python.hatch.cli.HatchCli +import com.jetbrains.python.PythonBinary +import com.jetbrains.python.Result +import com.jetbrains.python.errorProcessing.PyError +import java.nio.file.Path +import kotlin.io.path.isDirectory +import kotlin.io.path.isExecutable + +class HatchRuntime( + val hatchBinary: WhatToExec.Binary, + val execOptions: ExecOptions, + private val execService: ExecService = ExecService(), +) { + fun hatchCli(): HatchCli = HatchCli(this) + + fun withWorkingDirectory(workDirectoryPath: Path): Result { + if (!workDirectoryPath.isDirectory()) { + return Result.failure(WorkingDirectoryNotFoundHatchError(workDirectoryPath)) + } + + val execOptions = with(this.execOptions) { + ExecOptions(env, workDirectoryPath, processDescription, timeout) + } + return Result.success(HatchRuntime(this.hatchBinary, execOptions)) + } + + fun withBasePythonBinaryPath(basePythonPath: PythonBinary): Result { + if (!basePythonPath.isExecutable()) { + return Result.failure(BasePythonExecutableNotFoundHatchError(basePythonPath)) + } + + val execOptions = with(this.execOptions) { + val modifiedEnv = env + mapOf( + HatchConstants.AppEnvVars.PYTHON to basePythonPath.toString() + ) + ExecOptions(modifiedEnv, workingDirectory, processDescription, timeout) + } + return Result.success(HatchRuntime(this.hatchBinary, execOptions)) + } + + /** + * Pure execution of [hatchBinary] with command line [arguments] and [execOptions] by [execService] + * Doesn't make any validation of stdout/stderr content. + */ + internal suspend fun execute(vararg arguments: String, processOutputTransformer: ProcessOutputTransformer): Result { + return execService.execute(hatchBinary, arguments.toList(), execOptions, processOutputTransformer) + } + + internal suspend fun executeInteractive(vararg arguments: String, eelProcessInteractiveHandler: EelProcessInteractiveHandler): Result { + return execService.executeInteractive(hatchBinary, arguments.toList(), execOptions, eelProcessInteractiveHandler) + } +} + + +suspend fun createHatchRuntime( + hatchExecutablePath: Path?, + workingDirectoryPath: Path?, + envVars: Map = emptyMap(), + eelApi: EelApi = localEel, +): Result { + val actualHatchExecutable = hatchExecutablePath + ?: HatchConfiguration.getOrDetectHatchExecutablePath(eelApi).getOr { return it } + if (workingDirectoryPath?.isDirectory() != true) { + return Result.failure(WorkingDirectoryNotFoundHatchError(workingDirectoryPath)) + } + + val defaultVariables = mapOf( + HatchConstants.AppEnvVars.NO_COLOR to "1", + HatchConstants.AppEnvVars.VERBOSE to "1", + HatchConstants.AppEnvVars.INTERACTIVE to "0", + "TERM" to "dumb", + "COLUMNS" to "${0x7FFF}", + "LINES" to "${0x7FFF}", + ) + val actualEnvVars = defaultVariables + envVars + + val runtime = HatchRuntime( + hatchBinary = WhatToExec.Binary(actualHatchExecutable), + execOptions = ExecOptions( + env = actualEnvVars, + workingDirectory = workingDirectoryPath + ) + ) + return Result.success(runtime) +} diff --git a/python/python-hatch/src/com/intellij/python/hatch/runtime/utils.kt b/python/python-hatch/src/com/intellij/python/hatch/runtime/utils.kt new file mode 100644 index 000000000000..6317083365ed --- /dev/null +++ b/python/python-hatch/src/com/intellij/python/hatch/runtime/utils.kt @@ -0,0 +1,9 @@ +package com.intellij.python.hatch.runtime + +import com.intellij.platform.eel.EelApi +import com.intellij.platform.eel.EelPlatform + +fun EelApi.getHatchCommand(): String = when (platform) { + is EelPlatform.Windows -> "hatch.exe" + else -> "hatch" +} diff --git a/python/python-hatch/src/com/intellij/python/hatch/service/CliBasedHatchService.kt b/python/python-hatch/src/com/intellij/python/hatch/service/CliBasedHatchService.kt new file mode 100644 index 000000000000..c3ff259fda6d --- /dev/null +++ b/python/python-hatch/src/com/intellij/python/hatch/service/CliBasedHatchService.kt @@ -0,0 +1,123 @@ +package com.intellij.python.hatch.service + +import com.intellij.platform.eel.fs.EelFileSystemApi +import com.intellij.platform.eel.getOr +import com.intellij.platform.eel.provider.asEelPath +import com.intellij.platform.eel.provider.asNioPath +import com.intellij.platform.eel.provider.getEelDescriptor +import com.intellij.python.community.execService.ExecService +import com.intellij.python.community.execService.WhatToExec.Binary +import com.intellij.python.hatch.* +import com.intellij.python.hatch.cli.HatchEnvironmentType +import com.intellij.python.hatch.runtime.HatchRuntime +import com.intellij.python.hatch.runtime.createHatchRuntime +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread +import com.jetbrains.python.PythonBinary +import com.jetbrains.python.PythonHomePath +import com.jetbrains.python.Result +import com.jetbrains.python.errorProcessing.PyError +import com.jetbrains.python.resolvePythonBinary +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.isDirectory +import kotlin.io.path.isRegularFile +import kotlin.io.path.readText + +internal class CliBasedHatchService private constructor( + private val workingDirectoryPath: Path, + private val hatchRuntime: HatchRuntime, +) : HatchService { + companion object { + suspend operator fun invoke(workingDirectoryPath: Path, hatchExecutablePath: Path?): Result { + val hatchRuntime = createHatchRuntime( + hatchExecutablePath=hatchExecutablePath, + workingDirectoryPath=workingDirectoryPath, + ).getOr { return it } + return Result.success(CliBasedHatchService(workingDirectoryPath, hatchRuntime)) + } + } + + override fun getWorkingDirectoryPath(): Path = workingDirectoryPath + + @RequiresBackgroundThread + override suspend fun isHatchManagedProject(): Result { + val isHatchManaged = when { + workingDirectoryPath.resolve("hatch.toml").exists() -> true + else -> { + val pyProjectTomlPath = workingDirectoryPath.resolve("pyproject.toml").takeIf { it.isRegularFile() } + val hatchRegex = """^\[tool\.hatch\..+]$""".toRegex(RegexOption.MULTILINE) + pyProjectTomlPath?.readText()?.contains(hatchRegex) == true + } + } + return Result.success(isHatchManaged) + } + + @RequiresBackgroundThread + override suspend fun findStandaloneEnvironments(): Result, PyError> { + val hatchEnv = hatchRuntime.hatchCli().env() + val environments = hatchEnv.show().getOr { return it } + val standaloneEnvironments = environments.getOrDefault(HatchEnvironmentType.STANDALONE, emptyList()) + + val available = standaloneEnvironments.mapNotNull { env -> + val pythonHomePath = hatchEnv.find(env.name).getOr { return@mapNotNull null } ?: return@mapNotNull null + val pythonVirtualEnvironment = pythonHomePath.toPythonVirtualEnvironment().getOr { return@mapNotNull null } + HatchStandaloneEnvironment( + hatchEnvironment = env, + pythonVirtualEnvironment = pythonVirtualEnvironment + ) + } + + return Result.success(available) + } + + @RequiresBackgroundThread + override suspend fun createNewProject(projectName: String): Result { + val eelApi = workingDirectoryPath.getEelDescriptor().upgrade() + val tempDir = eelApi.fs.createTemporaryDirectory(EelFileSystemApi.CreateTemporaryEntryOptions.Builder().build()).getOr { failure -> + return Result.failure(FileSystemOperationHatchError(failure.error)) + } + + hatchRuntime.hatchCli().new(projectName, tempDir.asNioPath()).getOr { return it } + eelApi.fs.move( + tempDir, + workingDirectoryPath.asEelPath(), + EelFileSystemApi.ReplaceExistingDuringMove.DO_NOT_REPLACE_DIRECTORIES, + true + ).getOr { failure -> + return Result.failure(FileSystemOperationHatchError(failure.error)) + } + + return Result.success(Unit) + } + + @RequiresBackgroundThread + override suspend fun createVirtualEnvironment(basePythonBinaryPath: PythonBinary?, envName: String?): Result { + val pythonBasedRuntime = basePythonBinaryPath?.let { path -> + hatchRuntime.withBasePythonBinaryPath(path).getOr { return it } + } ?: hatchRuntime + + val hatchEnv = pythonBasedRuntime.hatchCli().env() + + hatchEnv.create(envName).getOr { return it } + val pythonHomePath = hatchEnv.find(envName).getOr { return it } + val pythonVirtualEnvironment = pythonHomePath?.toPythonVirtualEnvironment()?.getOr { return it } + + val result = when (pythonVirtualEnvironment) { + is PythonVirtualEnvironment.Existing -> Result.success(pythonVirtualEnvironment) + else -> Result.failure(EnvironmentCreationHatchError("Hatch didn't create environment but responded with ok")) + } + return result + } +} + +@RequiresBackgroundThread +internal suspend fun PythonHomePath.toPythonVirtualEnvironment(): Result { + val pythonVersion = this.takeIf { it.isDirectory() }?.resolvePythonBinary()?.let { pythonBinaryPath -> + ExecService().execGetStdout(Binary(pythonBinaryPath), listOf("--version")).getOr { return it }.trim() + } + val pythonVirtualEnvironment = when { + pythonVersion == null -> PythonVirtualEnvironment.NotExisting(this) + else -> PythonVirtualEnvironment.Existing(this, pythonVersion) + } + return Result.success(pythonVirtualEnvironment) +} \ No newline at end of file