PY-52688 support Poetry for remote targets

(cherry picked from commit 29f62aa09b653caf57a7d2c1a577f1eea9509d66)

IJ-MR-215198

GitOrigin-RevId: 548f46186b0fa110776c1539b51d08ba659f9012
This commit is contained in:
Alexey Katsman
2026-07-24 21:01:10 +00:00
committed by intellij-monorepo-bot
parent 8b9a94a301
commit eab2befd1a
19 changed files with 415 additions and 125 deletions
@@ -586,6 +586,7 @@ sdk.create.custom.hatch.error.execution.failed=Please verify Hatch tool, execute
sdk.create.custom.hatch.error.environment.is.not.selected=Hatch environment is not selected
sdk.create.custom.hatch.error.hatch.executable.path.is.not.valid=Hatch executable path is not valid: {0}
sdk.create.custom.hatch.error.base.python.executable.is.not.available=Base Python executable is not available
sdk.create.custom.poetry.error.poetry.executable.path.is.not.valid=Poetry executable path is not valid
sdk.create.targets.local=Local Machine
sdk.create.custom.virtualenv=Virtualenv
@@ -3,13 +3,13 @@ package com.intellij.python.hatch.impl
import com.intellij.python.community.common.tools.ToolId
import com.intellij.python.hatch.icons.PythonHatchIcons
import com.intellij.python.hatch.impl.sdk.HatchSdkFlavor
import com.intellij.python.pyproject.model.spi.ProjectName
import com.intellij.python.pyproject.model.spi.ProjectStructureInfo
import com.intellij.python.pyproject.model.spi.PyProjectTomlProject
import com.intellij.python.pyproject.model.spi.PyProjectManager
import com.intellij.python.pyproject.model.spi.TomlDependencySpecification
import com.jetbrains.python.PyToolUIInfo
import com.jetbrains.python.hatch.sdk.HatchSdkAdditionalData
import com.jetbrains.python.venvReader.Directory
import org.apache.tuweni.toml.TomlTable
import org.jetbrains.annotations.ApiStatus
@@ -23,7 +23,7 @@ internal class HatchPyProjectManager : PyProjectManager {
override val id: ToolId = HATCH_TOOL_ID
override val ui: PyToolUIInfo = HATCH_UI_INFO
override val additionalDataType: Class<HatchSdkAdditionalData> = HatchSdkAdditionalData::class.java
override val flavorDataType: Class<HatchSdkFlavor> = HatchSdkFlavor::class.java
override suspend fun getSrcRoots(toml: TomlTable, projectRoot: Directory): Set<Directory> = emptySet()
@@ -13,7 +13,7 @@ import com.intellij.python.pyproject.model.spi.PySdkDependencyGroupSupport
import com.intellij.python.pyproject.model.spi.TomlDependencySpecification
import com.jetbrains.python.PyToolUIInfo
import com.jetbrains.python.sdk.poetry.PoetryDependencyGroupSupport
import com.jetbrains.python.sdk.poetry.PyPoetrySdkAdditionalData
import com.jetbrains.python.sdk.poetry.PyPoetrySdkFlavor
import com.jetbrains.python.venvReader.Directory
import org.apache.tuweni.toml.TomlTable
@@ -22,7 +22,7 @@ internal class PoetryPyProjectManager : PyProjectManager {
override val id: ToolId = POETRY_TOOL_ID
override val ui: PyToolUIInfo = POETRY_UI_INFO
override val additionalDataType: Class<PyPoetrySdkAdditionalData> = PyPoetrySdkAdditionalData::class.java
override val flavorDataType: Class<PyPoetrySdkFlavor> = PyPoetrySdkFlavor::class.java
override val dependencyGroupSupport: PySdkDependencyGroupSupport = PoetryDependencyGroupSupport
@@ -5,7 +5,7 @@ import com.intellij.openapi.projectRoots.Sdk
import com.intellij.python.community.common.tools.ToolId
import com.intellij.python.pyproject.dependencies.spi.PyDependencyGroupLocator
import com.jetbrains.python.PyToolUIInfo
import com.jetbrains.python.sdk.PythonSdkAdditionalData
import com.jetbrains.python.sdk.flavors.PythonSdkFlavor
import com.jetbrains.python.sdk.pySdkAdditionalData
import com.jetbrains.python.venvReader.Directory
import org.apache.tuweni.toml.TomlTable
@@ -20,14 +20,14 @@ interface PyProjectManager : PyDependencyGroupLocator {
fun forSdk(sdk: Sdk): PyProjectManager? {
val additionalData = sdk.pySdkAdditionalData
return EP.extensionList.firstOrNull { it.additionalDataType.isInstance(additionalData) }
return EP.extensionList.firstOrNull { it.flavorDataType.isInstance(additionalData.flavor) }
}
}
/**
* To be used by [forSdk]
*/
val additionalDataType: Class<out PythonSdkAdditionalData>
val flavorDataType: Class<out PythonSdkFlavor<*>>
/**
* CLI adapter for `pyproject.toml` dependency groups (PEP 735 / PEP 621), or `null` when this
@@ -9,6 +9,7 @@ import com.intellij.python.community.execService.DownloadConfig
import com.intellij.python.community.execService.ExecOptions
import com.intellij.python.community.execService.ExecService
import com.intellij.python.community.execService.ProcessOutputTransformer
import com.intellij.python.community.execService.UploadConfig
import com.intellij.python.community.execService.ZeroCodeStdoutTransformer
import com.intellij.python.community.execService.execute
import com.jetbrains.python.Result
@@ -37,9 +38,16 @@ suspend fun <T> runExecutableWithProgress(
transformer: ProcessOutputTransformer<T>,
execService: ExecService = ExecService(),
processWeight: ConcurrentProcessWeight = ConcurrentProcessWeight.LIGHT,
uploadConfig: UploadConfig? = null,
downloadConfig: DownloadConfig? = null,
): PyResult<T> {
val execOptions = ExecOptions(timeout = timeout, env = env, weight = processWeight, downloadAfterExecution = downloadConfig)
val execOptions = ExecOptions(
timeout = timeout,
env = env,
weight = processWeight,
uploadBeforeExecution = uploadConfig,
downloadAfterExecution = downloadConfig,
)
val errorHandlerTransformer: ProcessOutputTransformer<T> = { output ->
when {
@@ -5,7 +5,9 @@ import com.intellij.execution.Platform
import com.intellij.execution.target.FullPathOnTarget
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.util.NlsSafe
import com.intellij.python.community.execService.DownloadConfig
import com.intellij.python.community.execService.ProcessOutputTransformer
import com.intellij.python.community.execService.UploadConfig
import com.intellij.python.community.execService.ZeroCodeStdoutTransformer
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.isSuccess
@@ -69,12 +71,22 @@ data class ToolCommandExecutor(
dirPath: Path?,
vararg args: String,
env: Map<String, String> = emptyMap(),
uploadConfig: UploadConfig? = null,
downloadConfig: DownloadConfig? = null,
transformer: ProcessOutputTransformer<T>,
): PyResult<T> {
val executable = getToolExecutable(fileSystem, pathFromSdk)
?: return PyResult.localizedError(PySdkBundle.message("cannot.find.executable", toolName, fileSystem.userReadableName))
val bin = fileSystem.getBinaryToExec(executable, dirPath)
return runExecutableWithProgress(bin, 10.minutes, env = env, args = args, transformer = transformer)
return runExecutableWithProgress(
binaryToExec = bin,
timeout = 10.minutes,
env = env,
args = args,
uploadConfig = uploadConfig,
downloadConfig = downloadConfig,
transformer = transformer,
)
}
}
@@ -88,5 +100,16 @@ suspend fun <P : PathHolder> ToolCommandExecutor.runTool(
dirPath: Path?,
vararg args: String,
env: Map<String, String> = emptyMap(),
uploadConfig: UploadConfig? = null,
downloadConfig: DownloadConfig? = null,
): PyResult<String> =
runTool(fileSystem, pathFromSdk, dirPath, args = args, env = env, transformer = ZeroCodeStdoutTransformer)
runTool(
fileSystem,
pathFromSdk,
dirPath,
args = args,
env = env,
uploadConfig = uploadConfig,
downloadConfig = downloadConfig,
transformer = ZeroCodeStdoutTransformer,
)
@@ -17,7 +17,7 @@ import com.intellij.python.uv.common.UV_UI_INFO
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.jetbrains.python.PyToolUIInfo
import com.jetbrains.python.packaging.PyPackageName
import com.jetbrains.python.sdk.uv.UvSdkAdditionalData
import com.jetbrains.python.sdk.uv.UvSdkFlavor
import com.jetbrains.python.venvReader.Directory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -36,7 +36,7 @@ internal class UvPyProjectManager : PyProjectManager {
override val ui: PyToolUIInfo = UV_UI_INFO
override val additionalDataType: Class<UvSdkAdditionalData> get() = UvSdkAdditionalData::class.java
override val flavorDataType: Class<UvSdkFlavor> = UvSdkFlavor::class.java
override val dependencyGroupSupport: PySdkDependencyGroupSupport = UvDependencyGroupSupport
@@ -37,12 +37,19 @@ internal abstract class CustomExistingEnvironmentSelector<P : PathHolder>(
override fun setupUI(panel: Panel, validationRequestor: DialogValidationRequestor) {
with(panel) {
val missingExecutableText = if (model.fileSystem.toolPathCanBePersisted) {
message("sdk.create.custom.venv.missing.text", name)
}
else {
message("sdk.create.custom.tool.not.detected", name)
}
executablePath = validatablePathField(
fileSystem = model.fileSystem,
pathValidator = toolState,
validationRequestor = validationRequestor,
labelText = message("sdk.create.custom.venv.executable.path", name),
missingExecutableText = message("sdk.create.custom.venv.missing.text", name),
missingExecutableText = missingExecutableText,
canBeEdited = model.fileSystem.toolPathCanBePersisted,
)
comboBox = pythonInterpreterComboBox(
@@ -48,13 +48,20 @@ internal abstract class CustomNewEnvironmentCreator<P : PathHolder>(
onPathSelected = model::addManuallyAddedSystemPython,
)
val missingExecutableText = if (model.fileSystem.toolPathCanBePersisted) {
message("sdk.create.custom.venv.missing.text", pyTool.presentableName)
}
else {
message("sdk.create.custom.tool.not.detected", pyTool.presentableName)
}
executablePath = validatablePathField(
fileSystem = model.fileSystem,
pathValidator = toolValidator,
validationRequestor = validationRequestor,
labelText = message("sdk.create.custom.venv.executable.path", pyTool.presentableName),
missingExecutableText = message("sdk.create.custom.venv.missing.text", pyTool.presentableName),
missingExecutableText = missingExecutableText,
installAction = createInstallFix(errorSink),
canBeEdited = model.fileSystem.toolPathCanBePersisted,
)
row("") {
@@ -382,7 +382,7 @@ data class EelFileSystem(
data class TargetFileSystem(
val targetEnvironmentConfiguration: TargetEnvironmentConfiguration,
val pythonLanguageRuntimeConfiguration: PythonLanguageRuntimeConfiguration,
private val pythonLanguageRuntimeConfiguration: PythonLanguageRuntimeConfiguration,
) : FileSystem<PathHolder.Target> {
override val isReadOnly: Boolean
get() = !PythonInterpreterTargetEnvironmentFactory.isMutable(targetEnvironmentConfiguration)
@@ -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.jetbrains.python.sdk.add.v2
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import com.intellij.execution.target.TargetEnvironmentConfiguration
import com.intellij.openapi.components.Service
import com.jetbrains.python.target.PythonLanguageRuntimeConfiguration
import org.jetbrains.annotations.ApiStatus
import kotlin.time.Duration.Companion.minutes
import kotlin.time.toJavaDuration
@Service(Service.Level.APP)
@ApiStatus.Internal
class TargetFileSystemCache {
private data class CacheKey(private val targetId: String, private val runtimeProperties: PythonLanguageRuntimeConfiguration.State)
private val cache: Cache<CacheKey, TargetFileSystem> = Caffeine.newBuilder()
.expireAfterWrite(5.minutes.toJavaDuration())
.build()
fun getOrCreate(
targetConfig: TargetEnvironmentConfiguration,
runtimeConfiguration: PythonLanguageRuntimeConfiguration,
): TargetFileSystem = cache.get(CacheKey(targetConfig.uuid, runtimeConfiguration.state)) {
TargetFileSystem(targetConfig, runtimeConfiguration)
}
}
@@ -295,14 +295,14 @@ private fun <T, P : PathHolder, V : ValidatedPath<T, P>> Panel.installToolRow(
installAction: ActionLink,
validatedPathField: ValidatedPathField<T, P, V>,
): Row {
val selectExecutableLink = if (fileSystem.isBrowsable) ActionLink(message("sdk.create.custom.select.executable.link")) {
val selectExecutableLink = if (fileSystem.isBrowsable && fileSystem.toolPathCanBePersisted) ActionLink(message("sdk.create.custom.select.executable.link")) {
validatedPathField.button.doClick()
}
else null
return row("") {
validationTooltip(missingExecutableText,
installAction,
if (fileSystem.toolPathCanBePersisted) installAction else null,
selectExecutableLink,
validationType = ValidationType.WARNING,
inline = true)
@@ -144,7 +144,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),
POETRY(POETRY_TOOL_ID, "sdk.create.custom.poetry", PythonCommunityImplPoetryCommonIcons.Poetry, sshAutoUploadRequired = true, { true }),
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 = true, { true }),
@@ -193,12 +193,18 @@ private fun <P : PathHolder> Panel.addExecutableSelector(
): ValidatedPathField<Version, P, ValidatedPath.Executable<P>> {
val missingExecutableText = if (model.fileSystem.toolPathCanBePersisted) {
message("sdk.create.custom.venv.missing.text", "hatch")
}
else {
message("sdk.create.custom.tool.not.detected", "hatch")
}
val executablePath = validatablePathField(
fileSystem = model.fileSystem,
pathValidator = model.hatchViewModel.toolValidator,
validationRequestor = validationRequestor,
labelText = message("sdk.create.custom.venv.executable.path", "hatch"),
missingExecutableText = message("sdk.create.custom.tool.not.detected", "hatch"),
missingExecutableText = missingExecutableText,
installAction = installHatchActionLink,
canBeEdited = model.fileSystem.toolPathCanBePersisted,
)
@@ -20,13 +20,13 @@ 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
import com.jetbrains.python.PyBundle
import com.jetbrains.python.PyBundle.message
import com.jetbrains.python.errorProcessing.ErrorSink
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.newProjectWizard.collector.PythonNewProjectWizardCollector
import com.jetbrains.python.poetry.PoetryPyProjectTomlPythonVersionsService
import com.jetbrains.python.sdk.add.v2.CustomNewEnvironmentCreator
import com.jetbrains.python.sdk.add.v2.FileSystem
import com.jetbrains.python.sdk.add.v2.PathHolder
import com.jetbrains.python.sdk.add.v2.PythonInterpreterSelectionMethod.SELECT_EXISTING
import com.jetbrains.python.sdk.add.v2.PythonMutableTargetAddInterpreterModel
@@ -119,19 +119,22 @@ internal class EnvironmentCreatorPoetry<P : PathHolder>(
override suspend fun setupEnvSdk(moduleBasePath: Path): PyResult<Sdk> {
val basePythonBinaryPath = model.getOrInstallBasePython()
?: return PyResult.localizedError(message("python.sdk.provided.path.is.invalid", null))
val poetryExecutable = model.poetryViewModel.poetryExecutable.get()?.pathHolder
?: return PyResult.localizedError(message("sdk.create.custom.poetry.error.poetry.executable.path.is.not.valid"))
service<PoetryConfigService>().updateExistingPoetryToml(moduleBasePath)
return when (basePythonBinaryPath) {
is PathHolder.Eel -> withProgressText(message("python.sdk.progress.poetry.creating")) {
createNewPoetrySdk(
moduleBasePath = moduleBasePath,
basePythonBinaryPath = basePythonBinaryPath.path,
installPackages = false,
errorSink = errorSink,
inProjectEnv = isInProjectEnvFlow.value,
)
}
else -> PyResult.localizedError(message("target.is.not.supported", basePythonBinaryPath))
service<PoetryConfigService>().updateExistingPoetryToml(moduleBasePath, model.fileSystem, poetryExecutable)
return withProgressText(message("python.sdk.progress.poetry.creating")) {
createNewPoetrySdk(
moduleBasePath = moduleBasePath,
basePythonBinaryPath = basePythonBinaryPath,
fileSystem = model.fileSystem,
poetryExecutable = poetryExecutable,
installPackages = false,
errorSink = errorSink,
inProjectEnv = isInProjectEnvFlow.value,
targetPanelExtension = model.state.targetPanelExtension.get(),
)
}
}
@@ -164,12 +167,21 @@ private class PoetryConfigService :
var isInProjectEnv = false
}
suspend fun updateExistingPoetryToml(moduleBasePath: Path) {
suspend fun <P : PathHolder> updateExistingPoetryToml(
moduleBasePath: Path,
fileSystem: FileSystem<P>,
poetryExecutable: P?,
) {
val poetryTomlExists = withContext(Dispatchers.IO) {
moduleBasePath.resolve("poetry.toml").exists()
}
if (poetryTomlExists) {
configurePoetryEnvironment(moduleBasePath, "virtualenvs.in-project", state.isInProjectEnv.toString(), "--local")
configurePoetryEnvironment(
modulePath = moduleBasePath,
fileSystem = fileSystem,
poetryExecutable = poetryExecutable,
"virtualenvs.in-project", state.isInProjectEnv.toString(), "--local",
)
}
}
}
}
@@ -5,7 +5,6 @@ import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.module.Module
import com.intellij.openapi.observable.properties.ObservableProperty
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.python.community.execService.ExecService
import com.intellij.python.community.execService.python.validatePythonAndGetInfo
import com.intellij.python.community.impl.poetry.common.POETRY_UI_INFO
import com.intellij.python.community.impl.poetry.common.poetryPath
@@ -20,15 +19,13 @@ import com.jetbrains.python.sdk.add.v2.PythonMutableTargetAddInterpreterModel
import com.jetbrains.python.sdk.add.v2.ToolValidator
import com.jetbrains.python.sdk.add.v2.ValidatedPath
import com.jetbrains.python.sdk.add.v2.savePathForEelOnly
import com.jetbrains.python.sdk.baseDir
import com.jetbrains.python.sdk.legacy.PythonSdkUtil
import com.jetbrains.python.sdk.moduleIfExists
import com.jetbrains.python.sdk.poetry.createPoetrySdk
import com.jetbrains.python.sdk.poetry.detectPoetryEnvs
import com.jetbrains.python.sdk.poetry.isPoetry
import com.jetbrains.python.sdk.workingDirectory
import com.jetbrains.python.statistics.InterpreterType
import java.nio.file.Path
import kotlin.io.path.pathString
internal class PoetryExistingEnvironmentSelector<P : PathHolder>(model: PythonMutableTargetAddInterpreterModel<P>, module: Module?) :
CustomExistingEnvironmentSelector<P>("poetry", model, module) {
@@ -41,7 +38,7 @@ internal class PoetryExistingEnvironmentSelector<P : PathHolder>(model: PythonMu
override suspend fun getOrCreateSdk(moduleOrProject: ModuleOrProject): PyResult<Sdk> {
val pythonBinaryPath = selectedEnv.get()?.homePath as? PathHolder.Eel
val pythonBinaryPath = selectedEnv.get()?.homePath
?: return PyResult.localizedError(PyBundle.message("python.sdk.provided.path.is.invalid",
selectedEnv.get()?.homePath))
@@ -49,18 +46,23 @@ internal class PoetryExistingEnvironmentSelector<P : PathHolder>(model: PythonMu
return Result.success(it)
}
val basePathString = moduleOrProject.moduleIfExists?.baseDir?.path ?: moduleOrProject.project.basePath
val basePath = basePathString?.let { Path.of(it) } ?: error("module base path is not valid: $basePathString")
val basePath = moduleOrProject.workingDirectory
?: return PyResult.localizedError(PyBundle.message("python.sdk.project.working.directory.not.found"))
return createPoetrySdk(basePath, pythonBinaryPath)
return createPoetrySdk(
basePath = basePath,
pythonBinaryPath = pythonBinaryPath,
fileSystem = model.fileSystem,
targetPanelExtension = model.state.targetPanelExtension.get(),
)
}
override suspend fun detectEnvironments(modulePath: Path): List<DetectedSelectableInterpreter<P>> {
val existingEnvs = detectPoetryEnvs(modulePath).mapNotNull { pythonBinary ->
val pythonInfo = ExecService().validatePythonAndGetInfo(pythonBinary).successOrNull ?: return@mapNotNull null
model.fileSystem.parsePath(pythonBinary.pathString).successOrNull?.let { fsPath ->
DetectedSelectableInterpreter(fsPath, pythonInfo, false, POETRY_UI_INFO)
}
val poetryExecutable = model.poetryViewModel.poetryExecutable.get()?.pathHolder
val existingEnvs = detectPoetryEnvs(modulePath, model.fileSystem, poetryExecutable).mapNotNull { pythonBinary ->
val pythonInfo = model.fileSystem.getBinaryToExec(pythonBinary).validatePythonAndGetInfo().successOrNull
?: return@mapNotNull null
DetectedSelectableInterpreter(pythonBinary, pythonInfo, false, POETRY_UI_INFO)
}
return existingEnvs
}
@@ -2,12 +2,17 @@
package com.jetbrains.python.sdk.poetry
import com.intellij.execution.Platform
import com.intellij.openapi.components.service
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.util.registry.Registry
import com.intellij.platform.eel.EelApi
import com.intellij.platform.eel.provider.localEel
import com.intellij.python.community.execService.DownloadConfig
import com.intellij.python.community.execService.UploadConfig
import com.intellij.python.community.execService.python.validatePythonAndGetInfo
import com.intellij.python.community.impl.poetry.common.poetryPath
import com.intellij.python.pyproject.PY_PROJECT_TOML
import com.jetbrains.python.PyBundle
import com.jetbrains.python.PythonBinary
import com.jetbrains.python.PythonHomePath
import com.jetbrains.python.errorProcessing.ErrorSink
@@ -27,21 +32,20 @@ import com.jetbrains.python.sdk.ToolSearchPath
import com.jetbrains.python.sdk.add.v2.EelFileSystem
import com.jetbrains.python.sdk.add.v2.FileSystem
import com.jetbrains.python.sdk.add.v2.PathHolder
import com.jetbrains.python.sdk.add.v2.TargetFileSystemCache
import com.jetbrains.python.sdk.add.v2.toEelFileSystem
import com.jetbrains.python.sdk.impl.PySdkBundle
import com.jetbrains.python.sdk.pyRichSdkAsync
import com.jetbrains.python.sdk.pySdkAdditionalData
import com.jetbrains.python.sdk.runTool
import com.jetbrains.python.venvReader.VirtualEnvReader
import com.jetbrains.python.target.PyTargetAwareAdditionalData
import com.jetbrains.python.target.PythonLanguageRuntimeConfiguration
import io.github.z4kn4fein.semver.Version
import io.github.z4kn4fein.semver.toVersion
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.apache.tuweni.toml.Toml
import org.jetbrains.annotations.ApiStatus.Internal
import java.nio.file.Path
import kotlin.io.path.isRegularFile
import kotlin.io.path.name
import kotlin.io.path.pathString
/**
* This source code is edited by @koxudaxi Koudai Aono <koxudaxi@gmail.com>
@@ -57,6 +61,40 @@ internal val POETRY_TOOL: ToolCommandExecutor = ToolCommandExecutor(
)
private val POETRY_EXCLUDE_NON_DIGITS_REGEX = Regex("""\D+$""")
private const val POETRY_TOML = "poetry.toml"
private const val POETRY_LOCK = "poetry.lock"
private val POETRY_PROJECT_FILES = listOf(PY_PROJECT_TOML, POETRY_LOCK, POETRY_TOML)
private val POETRY_PROJECT_DOWNLOAD_CONFIG = DownloadConfig(relativePaths = POETRY_PROJECT_FILES)
private val POETRY_PROJECT_MUTATING_COMMANDS = setOf("add", "config", "init", "install", "lock", "new", "remove", "update")
private fun <P : PathHolder> Path.createPoetryMetadataUploadConfig(fileSystem: FileSystem<P>): UploadConfig? =
if (fileSystem.isLocal) null
else UploadConfig(relativePaths = POETRY_PROJECT_FILES.filter { resolve(it).isRegularFile() })
@Internal
internal suspend fun <P : PathHolder> runPoetry(
fileSystem: FileSystem<P>,
projectPath: Path?,
vararg args: String,
poetryExecutable: P? = null,
inProjectEnv: Boolean? = null,
baseEnv: Map<String, String> = emptyMap(),
uploadConfig: UploadConfig? = null,
downloadConfig: DownloadConfig? = null,
): PyResult<String> {
val env = baseEnv.toMutableMap().apply {
if (inProjectEnv != null) put("POETRY_VIRTUALENVS_IN_PROJECT", inProjectEnv.toString())
}
return POETRY_TOOL.runTool(
fileSystem = fileSystem,
pathFromSdk = poetryExecutable?.toString(),
dirPath = projectPath,
args = args,
env = env,
uploadConfig = uploadConfig,
downloadConfig = downloadConfig,
)
}
@Internal
internal suspend fun runPoetry(
@@ -65,15 +103,12 @@ internal suspend fun runPoetry(
inProjectEnv: Boolean? = null,
baseEnv: Map<String, String> = emptyMap(),
): PyResult<String> {
val env = baseEnv.toMutableMap().apply {
if (inProjectEnv != null) put("POETRY_VIRTUALENVS_IN_PROJECT", inProjectEnv.toString())
}
return POETRY_TOOL.runTool(
return runPoetry(
fileSystem = projectPath.toEelFileSystem(),
pathFromSdk = null,
dirPath = projectPath,
projectPath = projectPath,
args = args,
env = env,
inProjectEnv = inProjectEnv,
baseEnv = baseEnv,
)
}
@@ -100,15 +135,53 @@ internal suspend fun getPoetryExecutable(eel: EelApi = localEel): Path? =
*/
@Internal
internal suspend fun runPoetryWithSdk(sdk: Sdk, vararg args: String): PyResult<String> {
val projectPath = sdk.pySdkAdditionalData.workingDirectory
val pythonHomePath = sdk.pyRichSdkAsync().pythonHomePath
?: return PyResult.localizedError(PySdkBundle.message("python.sdk.broken.configuration", sdk.name))
val data = sdk.pySdkAdditionalData
val projectPath = data.workingDirectory.takeIf { data.hasValidWorkingDirectory() }
?: return PyResult.localizedError(PyBundle.message("python.sdk.project.working.directory.not.found"))
val sdkHomePath = sdk.homePath
?: return PyResult.localizedError(PySdkBundle.message("python.sdk.broken.configuration", sdk.name))
return when (data) {
is PyTargetAwareAdditionalData -> {
val targetConfig = data.targetEnvironmentConfiguration
?: return PyResult.localizedError(PySdkBundle.message("python.sdk.broken.configuration", sdk.name))
val targetFileSystemCache = service<TargetFileSystemCache>()
runPoetryWithSdk(
fileSystem = targetFileSystemCache.getOrCreate(targetConfig, PythonLanguageRuntimeConfiguration()),
projectPath = projectPath,
sdkHomePath = sdkHomePath,
args = args,
)
}
else -> runPoetryWithSdk(
fileSystem = projectPath.toEelFileSystem(),
projectPath = projectPath,
sdkHomePath = sdkHomePath,
args = args,
)
}
}
private suspend fun <P : PathHolder> runPoetryWithSdk(
fileSystem: FileSystem<P>,
projectPath: Path,
sdkHomePath: String,
vararg args: String,
): PyResult<String> {
val pythonPath = fileSystem.parsePath(sdkHomePath).getOr { return it }
val pythonHomePath = fileSystem.resolvePythonHome(pythonPath)
val env = buildMap {
put("POETRY_VIRTUALENVS_IN_PROJECT", "false")
put("POETRY_VIRTUALENVS_PREFER_ACTIVE_PYTHON", "true")
put("VIRTUAL_ENV", pythonHomePath.toAbsolutePath().toString())
put("VIRTUAL_ENV", pythonHomePath.toString())
}
return runPoetry(projectPath, *args, baseEnv = env)
return runPoetry(
fileSystem = fileSystem,
projectPath = projectPath,
args = args,
baseEnv = env,
downloadConfig = POETRY_PROJECT_DOWNLOAD_CONFIG.takeIf { args.firstOrNull() in POETRY_PROJECT_MUTATING_COMMANDS },
)
}
@@ -118,14 +191,16 @@ internal suspend fun runPoetryWithSdk(sdk: Sdk, vararg args: String): PyResult<S
* @return the path to the poetry environment.
*/
@Internal
internal suspend fun setupPoetry(
internal suspend fun <P : PathHolder> setupPoetry(
projectPath: Path,
basePythonBinaryPath: PythonBinary,
fileSystem: FileSystem<P>,
poetryExecutable: P?,
basePythonBinaryPath: P,
installPackages: Boolean,
init: Boolean,
errorSink: ErrorSink,
inProjectEnv: Boolean = false,
): PyResult<PythonHomePath> {
): PyResult<P> {
if (init) {
// Build poetry init command with Python version constraint if available
val initArgs = mutableListOf("init", "-n")
@@ -137,39 +212,88 @@ internal suspend fun setupPoetry(
}
// Validate Python and get version info
val pythonInfo = basePythonBinaryPath.validatePythonAndGetInfo().getOr { return it }
val pythonInfo = fileSystem.getBinaryToExec(basePythonBinaryPath).validatePythonAndGetInfo().getOr { return it }
val major = pythonInfo.languageLevel.majorVersion
val minor = pythonInfo.languageLevel.minorVersion
// Add --python flag with caret constraint (e.g., "^3.10")
initArgs.add("--python")
initArgs.add("^$major.$minor")
runPoetry(projectPath, *initArgs.toTypedArray(), inProjectEnv = inProjectEnv).getOr { return it }
runPoetry(
fileSystem = fileSystem,
projectPath = projectPath,
args = initArgs.toTypedArray(),
poetryExecutable = poetryExecutable,
inProjectEnv = inProjectEnv,
downloadConfig = POETRY_PROJECT_DOWNLOAD_CONFIG,
).getOr { return it }
}
runPoetry(projectPath, "env", "use", basePythonBinaryPath.pathString, inProjectEnv = inProjectEnv).getOr { return it }
runPoetry(
fileSystem = fileSystem,
projectPath = projectPath,
"env", "use", basePythonBinaryPath.toString(),
poetryExecutable = poetryExecutable,
inProjectEnv = inProjectEnv,
).getOr { return it }
if (installPackages) {
runPoetry(projectPath, "install", "--no-root", inProjectEnv = inProjectEnv).onFailure { errorSink.emit(it) }
runPoetry(
fileSystem = fileSystem,
projectPath = projectPath,
"install", "--no-root",
poetryExecutable = poetryExecutable,
inProjectEnv = inProjectEnv,
downloadConfig = POETRY_PROJECT_DOWNLOAD_CONFIG,
).onFailure { errorSink.emit(it) }
}
return runPoetry(projectPath, "env", "info", "-p", inProjectEnv = inProjectEnv).mapSuccess { Path.of(it) }
val pythonHomePath = runPoetry(
fileSystem = fileSystem,
projectPath = projectPath,
"env", "info", "-p",
poetryExecutable = poetryExecutable,
inProjectEnv = inProjectEnv,
).getOr { return it }
return fileSystem.parsePath(pythonHomePath.trim())
}
internal suspend fun detectPoetryEnvs(searchPath: Path): List<PythonBinary> =
getPoetryEnvs(searchPath).mapNotNull { getPythonExecutable(it) }
internal suspend fun getPoetryVersion(): String? =
runPoetry(null, "--version")
.getOrNull()
?.split(' ')
?.lastOrNull()
?.replace(POETRY_EXCLUDE_NON_DIGITS_REGEX, "") // strip all non-numeric characters after the version
private suspend fun getPythonExecutable(homePathString: String): PythonBinary? = withContext(Dispatchers.IO) {
VirtualEnvReader().findPythonInPythonRoot(Path.of(homePathString))
@Internal
internal suspend fun setupPoetry(
projectPath: Path,
basePythonBinaryPath: PythonBinary,
installPackages: Boolean,
init: Boolean,
errorSink: ErrorSink,
inProjectEnv: Boolean = false,
): PyResult<PythonHomePath> {
val fileSystem = projectPath.toEelFileSystem()
return setupPoetry(
projectPath = projectPath,
fileSystem = fileSystem,
poetryExecutable = null,
basePythonBinaryPath = PathHolder.Eel(basePythonBinaryPath),
installPackages = installPackages,
init = init,
errorSink = errorSink,
inProjectEnv = inProjectEnv,
).mapSuccess { it.path }
}
internal suspend fun <P : PathHolder> detectPoetryEnvs(
searchPath: Path,
fileSystem: FileSystem<P>,
poetryExecutable: P? = null,
): List<P> = getPoetryEnvs(searchPath, fileSystem, poetryExecutable).mapNotNull { homePath ->
fileSystem.parsePath(homePath).successOrNull?.let { fileSystem.resolvePythonBinary(it) }
}
private suspend fun getPoetryVersion(sdk: Sdk): String? =
runPoetryWithSdk(sdk, "--version").getOrNull()?.parsePoetryVersion()
private fun String.parsePoetryVersion(): String? =
split(' ').lastOrNull()?.replace(POETRY_EXCLUDE_NON_DIGITS_REGEX, "")
/**
* Installs a Python package using Poetry.
* Runs `poetry add [packages] [extraArgs]`
@@ -226,7 +350,7 @@ internal suspend fun poetryShowOutdated(sdk: Sdk): PyResult<Map<String, PythonOu
@Internal
internal suspend fun poetryListPackages(sdk: Sdk): PyResult<Pair<List<PyPackage>, List<PyRequirement>>> {
val version = getPoetryVersion()?.toVersion()
val version = getPoetryVersion(sdk)?.toVersion()
// Ensure that the lock file is up to date.
if (!Registry.get("python.poetry.list.packages.without.lock").asBoolean() && !checkLock(sdk, version)) {
@@ -327,11 +451,36 @@ internal fun parsePoetryLockEditablePackages(lockContent: String): Map<String, S
* @param [args] A vararg array of String arguments to pass to the Poetry configuration command.
*/
@Internal
internal suspend fun configurePoetryEnvironment(modulePath: Path?, vararg args: String) {
runPoetry(modulePath, "config", *args)
internal suspend fun <P : PathHolder> configurePoetryEnvironment(
modulePath: Path?,
fileSystem: FileSystem<P>,
poetryExecutable: P? = null,
vararg args: String,
) {
runPoetry(
fileSystem = fileSystem,
projectPath = modulePath,
"config", *args,
poetryExecutable = poetryExecutable,
downloadConfig = POETRY_PROJECT_DOWNLOAD_CONFIG,
)
}
private suspend fun getPoetryEnvs(projectPath: Path): List<String> {
val executionResult = runPoetry(projectPath, "env", "list", "--full-path")
internal suspend fun configurePoetryEnvironment(modulePath: Path?, vararg args: String) {
configurePoetryEnvironment(modulePath, modulePath.toEelFileSystem(), args = args)
}
private suspend fun <P : PathHolder> getPoetryEnvs(
projectPath: Path,
fileSystem: FileSystem<P>,
poetryExecutable: P?,
): List<String> {
val executionResult = runPoetry(
fileSystem = fileSystem,
projectPath = projectPath,
"env", "list", "--full-path",
poetryExecutable = poetryExecutable,
uploadConfig = projectPath.createPoetryMetadataUploadConfig(fileSystem),
)
return executionResult.getOrNull()?.lineSequence()?.map { it.split(" ")[0] }?.filterNot { it.isEmpty() }?.toList() ?: emptyList()
}
@@ -5,7 +5,6 @@ import com.intellij.openapi.module.Module
import com.intellij.openapi.project.Project
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.python.pyproject.model.api.getPyProjectTomlFile
import com.jetbrains.python.sdk.associatedModuleNioPath
import com.jetbrains.python.sdk.findModuleForSdk
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.getOrNull
@@ -30,10 +29,10 @@ import com.jetbrains.python.packaging.packageRequirements.PackageStructureNode
import com.jetbrains.python.packaging.packageRequirements.TreeParser
import com.jetbrains.python.packaging.packageRequirements.collectAllNames
import com.jetbrains.python.packaging.pip.PipRepositoryManager
import com.jetbrains.python.packaging.pyRequirement
import com.intellij.python.pyproject.PY_PROJECT_TOML
import com.intellij.python.pyproject.PyProjectToml
import com.jetbrains.python.poetry.POETRY_LOCK
import com.jetbrains.python.sdk.pySdkAdditionalData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.annotations.ApiStatus
@@ -198,12 +197,13 @@ internal class PoetryPackageManager(project: Project, sdk: Sdk) : PythonPackageM
}
private suspend fun editablePackagesFromLock(): Map<String, URI?> {
val projectPath = sdk.associatedModuleNioPath ?: return emptyMap()
val data = sdk.pySdkAdditionalData
val workingDir = data.workingDirectory.takeIf { data.hasValidWorkingDirectory() } ?: return emptyMap()
val content = withContext(Dispatchers.IO) {
try { projectPath.resolve(POETRY_LOCK.value).readText() } catch (_: IOException) { null }
try { workingDir.resolve(POETRY_LOCK.value).readText() } catch (_: IOException) { null }
} ?: return emptyMap()
return parsePoetryLockEditablePackages(content).mapValues { (_, url) ->
url?.let { resolveEditableLocation(it, projectPath) }
url?.let { resolveEditableLocation(it, workingDir) }
}
}
@@ -314,4 +314,4 @@ fun parsePoetryShowOutdatedTest(input: String): Map<String, PythonOutdatedPackag
private fun PythonPackageInstallRequest.ByLocation.toPoetryArgument(): String {
if (location.scheme != "file") return location.toString()
return location.path
}
}
@@ -11,22 +11,20 @@ import com.jetbrains.python.PythonBinary
import com.jetbrains.python.errorProcessing.ErrorSink
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.packaging.common.PythonOutdatedPackage
import com.jetbrains.python.sdk.add.v2.FileSystem
import com.jetbrains.python.sdk.add.v2.PathHolder
import com.jetbrains.python.sdk.createSdk
import com.jetbrains.python.sdk.pySdkAdditionalData
import com.jetbrains.python.sdk.impl.resolvePythonBinary
import com.jetbrains.python.sdk.add.v2.toEelFileSystem
import com.jetbrains.python.sdk.legacy.PythonSdkUtil
import org.jetbrains.annotations.ApiStatus.Internal
import com.jetbrains.python.sdk.pySdkAdditionalData
import com.jetbrains.python.target.ui.TargetPanelExtension
import java.nio.file.Path
import java.util.regex.Pattern
import kotlin.io.path.pathString
@Internal
internal fun suggestedSdkName(basePath: Path): @NlsSafe String = "Poetry (${PathUtil.getFileName(basePath.pathString)})"
@Internal
internal suspend fun createNewPoetrySdk(
moduleBasePath: Path,
basePythonBinaryPath: PythonBinary,
@@ -34,37 +32,85 @@ internal suspend fun createNewPoetrySdk(
errorSink: ErrorSink,
inProjectEnv: Boolean = false,
): PyResult<Sdk> {
val pythonBinaryPath = setUpPoetry(moduleBasePath, basePythonBinaryPath, installPackages, errorSink, inProjectEnv).getOr { return it }
return createPoetrySdk(
basePath = moduleBasePath,
pythonBinaryPath = PathHolder.Eel(pythonBinaryPath)
val fileSystem = moduleBasePath.toEelFileSystem()
return createNewPoetrySdk(
moduleBasePath = moduleBasePath,
basePythonBinaryPath = PathHolder.Eel(basePythonBinaryPath),
fileSystem = fileSystem,
poetryExecutable = null,
installPackages = installPackages,
errorSink = errorSink,
inProjectEnv = inProjectEnv,
)
}
@Internal
internal suspend fun createPoetrySdk(
basePath: Path,
pythonBinaryPath: PathHolder.Eel,
): PyResult<Sdk> = withProgressText(PyBundle.message("python.sdk.progress.poetry.configuring")) {
createSdk(
internal suspend fun <P : PathHolder> createNewPoetrySdk(
moduleBasePath: Path,
basePythonBinaryPath: P,
fileSystem: FileSystem<P>,
poetryExecutable: P?,
installPackages: Boolean,
errorSink: ErrorSink,
inProjectEnv: Boolean = false,
targetPanelExtension: TargetPanelExtension? = null,
): PyResult<Sdk> {
val pythonBinaryPath = setUpPoetry(
moduleBasePath = moduleBasePath,
basePythonBinaryPath = basePythonBinaryPath,
fileSystem = fileSystem,
poetryExecutable = poetryExecutable,
installPackages = installPackages,
errorSink = errorSink,
inProjectEnv = inProjectEnv,
).getOr { return it }
return createPoetrySdk(
basePath = moduleBasePath,
pythonBinaryPath = pythonBinaryPath,
sdkAdditionalData = PyPoetrySdkAdditionalData(basePath)
)}
fileSystem = fileSystem,
targetPanelExtension = targetPanelExtension,
)
}
internal suspend fun <P : PathHolder> createPoetrySdk(
basePath: Path,
pythonBinaryPath: P,
fileSystem: FileSystem<P>,
targetPanelExtension: TargetPanelExtension? = null,
): PyResult<Sdk> = withProgressText(PyBundle.message("python.sdk.progress.poetry.configuring")) {
fileSystem.setupSdk(
project = null,
pythonBinaryPath = pythonBinaryPath,
sdkAdditionalData = PyPoetrySdkAdditionalData(basePath),
targetPanelExtension = targetPanelExtension,
suggestedSdkName = null,
)
}
internal val Sdk.isPoetry: Boolean
get() {
if (!PythonSdkUtil.isPythonSdk(this)) {
return false
}
get() = PythonSdkUtil.isPythonSdk(this) && pySdkAdditionalData.flavor == PyPoetrySdkFlavor
return pySdkAdditionalData is PyPoetrySdkAdditionalData
}
private suspend fun setUpPoetry(moduleBasePath: Path, basePythonBinaryPath: PythonBinary, installPackages: Boolean, errorSink: ErrorSink, inProjectEnv: Boolean = false): PyResult<PythonBinary> {
private suspend fun <P : PathHolder> setUpPoetry(
moduleBasePath: Path,
basePythonBinaryPath: P,
fileSystem: FileSystem<P>,
poetryExecutable: P?,
installPackages: Boolean,
errorSink: ErrorSink,
inProjectEnv: Boolean,
): PyResult<P> {
val init = PyProjectToml.findInRoot(moduleBasePath) == null
val pythonHomePath = setupPoetry(moduleBasePath, basePythonBinaryPath, installPackages, init, errorSink, inProjectEnv).getOr { return it }
val pythonBinaryPath = pythonHomePath.resolvePythonBinary()
val pythonHomePath = setupPoetry(
projectPath = moduleBasePath,
fileSystem = fileSystem,
poetryExecutable = poetryExecutable,
basePythonBinaryPath = basePythonBinaryPath,
installPackages = installPackages,
init = init,
errorSink = errorSink,
inProjectEnv = inProjectEnv,
).getOr { return it }
val pythonBinaryPath = fileSystem.resolvePythonBinary(pythonHomePath)
?: return PyResult.localizedError(PyBundle.message("python.sdk.cannot.setup.sdk", pythonHomePath))
return PyResult.success(pythonBinaryPath)
}