PY-52688 support pipenv for remote targets

GitOrigin-RevId: 422467f4730b890eaf112f4ed3d82b6cd0f0907d
This commit is contained in:
Alexey Katsman
2026-07-24 18:06:13 +00:00
committed by intellij-monorepo-bot
parent c4bf56ea0f
commit f7bb047bb8
7 changed files with 169 additions and 77 deletions
@@ -15,24 +15,20 @@ import com.jetbrains.python.packaging.management.PythonPackageInstallRequest
import com.jetbrains.python.packaging.management.PythonPackageManager
import com.jetbrains.python.packaging.management.PythonRepositoryManager
import com.jetbrains.python.packaging.pip.PipRepositoryManager
import com.jetbrains.python.sdk.associatedModulePath
import com.jetbrains.python.sdk.pipenv.PIP_FILE
import com.jetbrains.python.sdk.pipenv.runPipEnv
import com.jetbrains.python.sdk.pipenv.runPipEnvWithSdk
import java.nio.file.Path
import com.jetbrains.python.sdk.pipenv.PipEnvParser as SdkPipEnvParser
internal class PipEnvPackageManager(project: Project, sdk: Sdk) : PythonPackageManager(project, sdk) {
private val modulePath: Path?
get() = sdk.associatedModulePath?.let { Path.of(it) }
override val repositoryManager: PythonRepositoryManager = PipRepositoryManager.getInstance(project)
override suspend fun syncLockedCommand(): PyResult<Unit> {
return runPipEnv(modulePath, "install", "--dev").mapSuccess { }
return runPipEnvWithSdk(sdk, "install", "--dev").mapSuccess { }
}
suspend fun lock(): PyResult<Unit> {
return runPipEnv(modulePath, "lock").mapSuccess { }
return runPipEnvWithSdk(sdk, "lock").mapSuccess { }
}
override suspend fun installPackageCommand(installRequest: PythonPackageInstallRequest, options: List<String>, module: Module?, dependencyGroup: PyDependencyGroup?): PyResult<Unit> {
@@ -41,24 +37,24 @@ internal class PipEnvPackageManager(project: Project, sdk: Sdk) : PythonPackageM
is PythonPackageInstallRequest.ByRepositoryPythonPackageSpecifications -> {
val args = listOf("install") + installRequest.specifications.map { it.nameWithVersionSpec } + options
runPipEnv(modulePath, *args.toTypedArray()).mapSuccess { }
runPipEnvWithSdk(sdk, *args.toTypedArray()).mapSuccess { }
}
}
}
override suspend fun updatePackageCommand(vararg specifications: PythonRepositoryPackageSpecification): PyResult<Unit> {
val args = listOf("install") + specifications.map { it.nameWithVersionSpec }
return runPipEnv(modulePath, *args.toTypedArray()).mapSuccess { }
return runPipEnvWithSdk(sdk, *args.toTypedArray()).mapSuccess { }
}
override suspend fun uninstallPackageCommand(vararg pythonPackages: String, workspaceMember: PyWorkspaceMember?, dependencyGroup: PyDependencyGroup?): PyResult<Unit> {
val args = listOf("uninstall") + pythonPackages.toList()
return runPipEnv(modulePath, *args.toTypedArray()).mapSuccess { }
return runPipEnvWithSdk(sdk, *args.toTypedArray()).mapSuccess { }
}
override suspend fun loadPackagesCommand(): PyResult<List<PythonPackage>> {
val output = runPipEnv(modulePath, "graph", "--json")
val output = runPipEnvWithSdk(sdk, "graph", "--json")
output.getOr {
return it
}
@@ -72,7 +68,7 @@ internal class PipEnvPackageManager(project: Project, sdk: Sdk) : PythonPackageM
}
override suspend fun loadOutdatedPackagesCommand(): PyResult<List<PythonOutdatedPackage>> {
val output = runPipEnv(modulePath, "update", "--dry-run").getOr { return it }
val output = runPipEnvWithSdk(sdk, "update", "--dry-run").getOr { return it }
val outdated = PipEnvParser.parseOutdatedPackagesOutput(output)
return PyResult.success(outdated)
@@ -87,4 +83,4 @@ internal class PipEnvPackageManager(project: Project, sdk: Sdk) : PythonPackageM
override val dependenciesFilesRelativePaths: List<Path>
get() =
listOf(Path.of(PIP_FILE))
}
}
@@ -145,7 +145,7 @@ enum class PythonSupportedEnvironmentManagers(
VIRTUALENV(VENV_TOOL_ID, "sdk.create.custom.virtualenv", PythonVenvIcons.VirtualEnv, sshAutoUploadRequired = false, { true }),
CONDA(CONDA_TOOL_ID, "sdk.create.custom.conda", PythonCommunityImplCondaIcons.Anaconda, sshAutoUploadRequired = false, { true }),
POETRY(POETRY_TOOL_ID, "sdk.create.custom.poetry", PythonCommunityImplPoetryCommonIcons.Poetry, sshAutoUploadRequired = true, { true }),
PIPENV(PIPENV_TOOL_ID, "sdk.create.custom.pipenv", PythonCommunityImplPipenvIcons.PythonClosed, sshAutoUploadRequired = false),
PIPENV(PIPENV_TOOL_ID, "sdk.create.custom.pipenv", PythonCommunityImplPipenvIcons.PythonClosed, sshAutoUploadRequired = true, { true }),
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 }),
PYTHON(VENV_TOOL_ID, "sdk.create.custom.python", PythonParserIcons.PythonFile, sshAutoUploadRequired = false, { true })
@@ -8,7 +8,6 @@ import com.intellij.python.community.impl.pipenv.PipEnvPyTool
import com.intellij.python.community.impl.pipenv.pipenvPath
import com.intellij.python.pytools.PyTool
import com.intellij.platform.util.progress.withProgressText
import com.jetbrains.python.PyBundle
import com.jetbrains.python.PyBundle.message
import com.jetbrains.python.errorProcessing.ErrorSink
import com.jetbrains.python.errorProcessing.PyResult
@@ -34,16 +33,19 @@ internal class EnvironmentCreatorPip<P : PathHolder>(model: PythonMutableTargetA
override suspend fun setupEnvSdk(moduleBasePath: Path): PyResult<Sdk> {
val basePythonBinaryPath = model.getOrInstallBasePython()
?: return PyResult.localizedError(message("python.sdk.provided.path.is.invalid", null))
val pipenvExecutable = model.pipenvViewModel.pipenvExecutable.get()?.pathHolder
?: return PyResult.localizedError(message("python.sdk.provided.path.is.invalid", null))
return when (basePythonBinaryPath) {
is PathHolder.Eel -> withProgressText(message("python.sdk.progress.pipenv.creating")) {
setupPipEnvSdkWithProgressReport(
moduleBasePath = moduleBasePath,
basePythonBinaryPath = basePythonBinaryPath.path,
installPackages = false
)
}
else -> PyResult.localizedError(message("target.is.not.supported", basePythonBinaryPath))
return withProgressText(message("python.sdk.progress.pipenv.creating")) {
setupPipEnvSdkWithProgressReport(
moduleBasePath = moduleBasePath,
basePythonBinaryPath = basePythonBinaryPath,
fileSystem = model.fileSystem,
pipenvExecutable = pipenvExecutable,
installPackages = false,
targetPanelExtension = model.state.targetPanelExtension.get(),
)
}
}
}
@@ -25,7 +25,6 @@ import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.onFailure
import com.jetbrains.python.packaging.utils.PyPackageCoroutine
import com.jetbrains.python.sdk.associatedModuleDir
import com.jetbrains.python.sdk.associatedModulePath
import com.jetbrains.python.sdk.findAmongRoots
import com.jetbrains.python.sdk.pythonSdk
import com.jetbrains.python.sdk.skeleton.PySkeletonUtil
@@ -33,7 +32,6 @@ import com.jetbrains.python.statistics.PipfileWatcherIdsHolder.Companion.RUN_PIP
import com.jetbrains.python.errorProcessing.ErrorSink
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.nio.file.Path
/**
* Watches for edits in Pipfiles inside modules with a pipenv SDK set.
@@ -114,7 +112,7 @@ internal class PipEnvPipFileWatcher : EditorFactoryListener {
PyPackageCoroutine.launch(module.project) {
withBackgroundProgress(module.project, description) {
val sdk = module.pythonSdk ?: return@withBackgroundProgress
runPipEnv(sdk.associatedModulePath?.let { Path.of(it) }, *args.toTypedArray()).onFailure {
runPipEnvWithSdk(sdk, *args.toTypedArray()).onFailure {
ErrorSink().emit(it, module.project)
}
@@ -139,4 +137,4 @@ internal class PipEnvPipFileWatcher : EditorFactoryListener {
get() = FileDocumentManager.getInstance().getFile(this)
private fun VirtualFile.getModule(project: Project): Module? = ModuleUtil.findModuleForFile(this, project)
}
}
@@ -1,9 +1,11 @@
// 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.pipenv
import com.intellij.openapi.components.service
import com.intellij.openapi.projectRoots.Sdk
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.ProcessOutputTransformer
import com.intellij.python.community.impl.pipenv.pipenvPath
import com.jetbrains.python.PyBundle
@@ -13,33 +15,52 @@ import com.jetbrains.python.sdk.ToolCommandExecutor
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.createSdk
import com.jetbrains.python.sdk.impl.PySdkBundle
import com.jetbrains.python.sdk.pySdkAdditionalData
import com.jetbrains.python.sdk.runTool
import com.jetbrains.python.venvReader.VirtualEnvReader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.jetbrains.python.target.PyTargetAwareAdditionalData
import com.jetbrains.python.target.PythonLanguageRuntimeConfiguration
import com.jetbrains.python.target.ui.TargetPanelExtension
import org.jetbrains.annotations.ApiStatus.Internal
import org.jetbrains.annotations.SystemDependent
import java.nio.file.Path
import kotlin.io.path.createFile
import kotlin.io.path.exists
import kotlin.io.path.pathString
internal val PIPENV_TOOL: ToolCommandExecutor = ToolCommandExecutor("pipenv") {
pipenvPath
}
private val PIPENV_PROJECT_DOWNLOAD_CONFIG = DownloadConfig(relativePaths = listOf(PIP_FILE, PIP_FILE_LOCK))
private val PIPENV_PROJECT_MUTATING_COMMANDS = setOf("--python", "install", "lock", "sync", "uninstall", "update")
internal suspend fun <P : PathHolder> runPipEnv(
fileSystem: FileSystem<P>,
dirPath: Path?,
vararg args: String,
pipenvExecutable: P? = null,
baseEnv: Map<String, String> = emptyMap(),
downloadConfig: DownloadConfig? = null,
): PyResult<String> =
PIPENV_TOOL.runTool(
fileSystem = fileSystem,
pathFromSdk = pipenvExecutable?.toString(),
dirPath = dirPath,
args = args,
env = baseEnv,
downloadConfig = downloadConfig,
)
@Internal
suspend fun runPipEnv(dirPath: Path?, vararg args: String): PyResult<String> =
PIPENV_TOOL.runTool(
runPipEnv(
fileSystem = dirPath.toEelFileSystem(),
pathFromSdk = null,
dirPath = dirPath,
args = args,
)
@Internal
internal suspend fun <T> runPipEnv(dirPath: Path?, vararg args: String, transformer: ProcessOutputTransformer<T>): PyResult<T> =
PIPENV_TOOL.runTool(
fileSystem = dirPath.toEelFileSystem(),
@@ -52,39 +73,88 @@ internal suspend fun <T> runPipEnv(dirPath: Path?, vararg args: String, transfor
/**
* Returns the configured pipenv executable or detects it automatically on the given [fileSystem].
*/
@Internal
internal suspend fun <P : PathHolder> getPipEnvExecutable(fileSystem: FileSystem<P>): P? =
PIPENV_TOOL.getToolExecutable(fileSystem, pathFromSdk = null)
/**
* Returns the configured pipenv executable or detects it automatically.
*/
@Internal
internal suspend fun getPipEnvExecutable(eel: EelApi = localEel): Path? =
getPipEnvExecutable(EelFileSystem(eel))?.path
internal suspend fun runPipEnvWithSdk(sdk: Sdk, vararg args: String): PyResult<String> {
val data = sdk.pySdkAdditionalData
val workingDirectory = 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))
runPipEnvWithSdk(
fileSystem = service<TargetFileSystemCache>().getOrCreate(targetConfig, PythonLanguageRuntimeConfiguration()),
workingDirectory = workingDirectory,
sdkHomePath = sdkHomePath,
args = args,
)
}
else -> runPipEnvWithSdk(
fileSystem = workingDirectory.toEelFileSystem(),
workingDirectory = workingDirectory,
sdkHomePath = sdkHomePath,
args = args,
)
}
}
private suspend fun <P : PathHolder> runPipEnvWithSdk(
fileSystem: FileSystem<P>,
workingDirectory: Path,
sdkHomePath: String,
vararg args: String,
): PyResult<String> {
val pythonPath = fileSystem.parsePath(sdkHomePath).getOr { return it }
val pythonHomePath = fileSystem.resolvePythonHome(pythonPath)
return runPipEnv(
fileSystem = fileSystem,
dirPath = workingDirectory,
args = args,
baseEnv = mapOf("VIRTUAL_ENV" to pythonHomePath.toString()),
downloadConfig = PIPENV_PROJECT_DOWNLOAD_CONFIG.takeIf { args.firstOrNull() in PIPENV_PROJECT_MUTATING_COMMANDS },
)
}
/**
* Sets up the pipenv environment under the modal progress window.
*
* The pipenv is associated with the first valid object from this list:
*
* 1. New project specified by [newProjectPath]
* 2. Existing module specified by [module]
* 3. Existing project specified by [project]
* Sets up the pipenv environment for [moduleBasePath] and creates its SDK.
*
* @return the SDK for pipenv, not stored in the SDK table yet.
*/
@Internal
internal suspend fun setupPipEnvSdkWithProgressReport(
internal suspend fun <P : PathHolder> setupPipEnvSdkWithProgressReport(
moduleBasePath: Path,
basePythonBinaryPath: PythonBinary?,
basePythonBinaryPath: P?,
fileSystem: FileSystem<P>,
pipenvExecutable: P?,
installPackages: Boolean,
targetPanelExtension: TargetPanelExtension? = null,
): PyResult<Sdk> {
val pythonExecutablePath = setUpPipEnv(moduleBasePath, basePythonBinaryPath, installPackages).getOr { return it }
val pythonHomePath = setupPipEnv(
projectPath = moduleBasePath,
fileSystem = fileSystem,
pipenvExecutable = pipenvExecutable,
basePythonBinaryPath = basePythonBinaryPath,
installPackages = installPackages,
).getOr { return it }
val pythonBinaryPath = fileSystem.resolvePythonBinary(pythonHomePath)
?: return PyResult.localizedError(PyBundle.message("python.sdk.cannot.setup.sdk", pythonHomePath))
return createSdk(
pythonBinaryPath = PathHolder.Eel(pythonExecutablePath),
sdkAdditionalData = PyPipEnvSdkAdditionalData(moduleBasePath)
return fileSystem.setupSdk(
project = null,
pythonBinaryPath = pythonBinaryPath,
sdkAdditionalData = PyPipEnvSdkAdditionalData(moduleBasePath),
targetPanelExtension = targetPanelExtension,
suggestedSdkName = null,
)
}
@@ -93,12 +163,26 @@ internal suspend fun setupPipEnvSdkWithProgressReport(
*
* @return the path to the pipenv environment.
*/
@Internal
internal suspend fun setupPipEnv(
projectPath: Path,
basePythonBinaryPath: PythonBinary?,
installPackages: Boolean,
): PyResult<@SystemDependent String> {
): PyResult<@SystemDependent String> =
setupPipEnv(
projectPath = projectPath,
fileSystem = projectPath.toEelFileSystem(),
pipenvExecutable = null,
basePythonBinaryPath = basePythonBinaryPath?.let(PathHolder::Eel),
installPackages = installPackages,
).mapSuccess { it.toString() }
internal suspend fun <P : PathHolder> setupPipEnv(
projectPath: Path,
fileSystem: FileSystem<P>,
pipenvExecutable: P?,
basePythonBinaryPath: P?,
installPackages: Boolean,
): PyResult<P> {
val pipfile = projectPath.resolve(PIP_FILE)
if (!pipfile.exists()) {
@@ -111,22 +195,37 @@ internal suspend fun setupPipEnv(
when {
installPackages -> {
val pythonArgs = if (basePythonBinaryPath != null) listOf("--python", basePythonBinaryPath.pathString) else emptyList()
val pythonArgs = if (basePythonBinaryPath != null) listOf("--python", basePythonBinaryPath.toString()) else emptyList()
val command = pythonArgs + listOf("install", "--dev")
runPipEnv(projectPath, *command.toTypedArray()).getOr { return it }
runPipEnv(
fileSystem = fileSystem,
dirPath = projectPath,
args = command.toTypedArray(),
pipenvExecutable = pipenvExecutable,
downloadConfig = PIPENV_PROJECT_DOWNLOAD_CONFIG,
).getOr { return it }
}
basePythonBinaryPath != null ->
runPipEnv(projectPath, "--python", basePythonBinaryPath.pathString).getOr { return it }
runPipEnv(
fileSystem = fileSystem,
dirPath = projectPath,
"--python", basePythonBinaryPath.toString(),
pipenvExecutable = pipenvExecutable,
downloadConfig = PIPENV_PROJECT_DOWNLOAD_CONFIG,
).getOr { return it }
else ->
runPipEnv(projectPath, "run", "python", "-V").getOr { return it }
runPipEnv(
fileSystem = fileSystem,
dirPath = projectPath,
"run", "python", "-V",
pipenvExecutable = pipenvExecutable,
).getOr { return it }
}
return runPipEnv(projectPath, "--venv")
}
private suspend fun setUpPipEnv(moduleBasePath: Path, basePythonBinaryPath: PythonBinary?, installPackages: Boolean): PyResult<Path> {
val pipEnv = setupPipEnv(moduleBasePath, basePythonBinaryPath, installPackages).getOr { return it }
val pipEnvExecutablePathString = withContext(Dispatchers.IO) {
VirtualEnvReader().findPythonInPythonRoot(Path.of(pipEnv))?.toString()
} ?: return PyResult.localizedError(PyBundle.message("python.sdk.provided.path.is.invalid", pipEnv))
return PyResult.success(Path.of(pipEnvExecutablePathString))
val pythonHomePath = runPipEnv(
fileSystem = fileSystem,
dirPath = projectPath,
"--venv",
pipenvExecutable = pipenvExecutable,
).getOr { return it }
return fileSystem.parsePath(pythonHomePath.trim())
}
@@ -5,11 +5,13 @@ import com.intellij.python.community.impl.pipenv.PipEnvPyTool
import com.jetbrains.python.sdk.flavors.CPythonSdkFlavor
import com.jetbrains.python.sdk.flavors.PyFlavorData
import com.jetbrains.python.sdk.flavors.PythonFlavorProvider
import org.jetbrains.annotations.ApiStatus
import java.nio.file.Path
import javax.swing.Icon
internal object PyPipEnvSdkFlavor : CPythonSdkFlavor<PyFlavorData.Empty>() {
@ApiStatus.Internal
object PyPipEnvSdkFlavor : CPythonSdkFlavor<PyFlavorData.Empty>() {
override fun getIcon(): Icon = PipEnvPyTool.getInstance().icon
override fun getFlavorDataClass(): Class<PyFlavorData.Empty> = PyFlavorData.Empty::class.java
@@ -19,4 +21,4 @@ internal object PyPipEnvSdkFlavor : CPythonSdkFlavor<PyFlavorData.Empty>() {
internal class PyPipEnvSdkFlavorProvider : PythonFlavorProvider {
override fun getFlavor(): PyPipEnvSdkFlavor = PyPipEnvSdkFlavor
}
}
@@ -5,19 +5,14 @@ import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.util.NlsSafe
import com.intellij.util.PathUtil
import com.jetbrains.python.sdk.legacy.PythonSdkUtil
import com.jetbrains.python.sdk.pySdkAdditionalData
import org.jetbrains.annotations.ApiStatus.Internal
/**
* Tells if the SDK was added as a pipenv.
*/
internal val Sdk.isPipEnv: Boolean
get() {
if (!PythonSdkUtil.isPythonSdk(this)) {
return false
}
return sdkAdditionalData is PyPipEnvSdkAdditionalData
}
get() = PythonSdkUtil.isPythonSdk(this) && pySdkAdditionalData.flavor == PyPipEnvSdkFlavor
@Internal
fun suggestedSdkName(basePath: @NlsSafe String): @NlsSafe String = "Pipenv (${PathUtil.getFileName(basePath)})"
fun suggestedSdkName(basePath: @NlsSafe String): @NlsSafe String = "Pipenv (${PathUtil.getFileName(basePath)})"