From 0d8b5fc93e088f3b74b8544739452c824f2bab41 Mon Sep 17 00:00:00 2001 From: Andrey Vlasovskikh Date: Sun, 6 May 2018 19:25:42 +0300 Subject: [PATCH] PY-26492 Panel for adding pipenv interpreters --- .../src/com/jetbrains/python/sdk/PySdkExt.kt | 7 +- .../python/sdk/PySdkListCellRenderer.kt | 6 + .../python/sdk/PythonSdkAdditionalData.java | 17 +++ .../python/sdk/add/PyAddNewCondaEnvPanel.kt | 2 +- .../python/sdk/add/PyAddNewVirtualEnvPanel.kt | 2 +- .../python/sdk/add/PyAddPipEnvPanel.kt | 135 ++++++++++++++++++ .../python/sdk/add/PyAddSdkDialog.kt | 5 + .../jetbrains/python/sdk/flavors/pipenv.kt | 89 ++++++++++++ 8 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 python/src/com/jetbrains/python/sdk/add/PyAddPipEnvPanel.kt create mode 100644 python/src/com/jetbrains/python/sdk/flavors/pipenv.kt diff --git a/python/src/com/jetbrains/python/sdk/PySdkExt.kt b/python/src/com/jetbrains/python/sdk/PySdkExt.kt index ef8de74d9862..0d16b459052d 100644 --- a/python/src/com/jetbrains/python/sdk/PySdkExt.kt +++ b/python/src/com/jetbrains/python/sdk/PySdkExt.kt @@ -67,7 +67,8 @@ fun detectCondaEnvs(project: Project?, existingSdks: List): List, existingSdks: List, baseSdk: Sdk?, - associatedProjectPath: String?): Sdk? { + associatedProjectPath: String?, + suggestedSdkName: String?): Sdk? { val homeFile = try { val homePath = ProgressManager.getInstance().run(generateSdkHomePath) StandardFileSystems.local().refreshAndFindFileByPath(homePath) ?: @@ -78,7 +79,7 @@ fun createSdkByGenerateTask(generateSdkHomePath: Task.WithResult IconLoader.getTransparentIcon(icon) + // XXX: We cannot provide pipenv SDK flavor by path since it's just a regular virtualenv. Consider + // adding the SDK flavor based on the `Sdk` object itself + sdk.isPipEnv -> + PIPENV_ICON else -> icon } diff --git a/python/src/com/jetbrains/python/sdk/PythonSdkAdditionalData.java b/python/src/com/jetbrains/python/sdk/PythonSdkAdditionalData.java index 5deee74d9853..f4cc414d79a8 100644 --- a/python/src/com/jetbrains/python/sdk/PythonSdkAdditionalData.java +++ b/python/src/com/jetbrains/python/sdk/PythonSdkAdditionalData.java @@ -47,6 +47,7 @@ public class PythonSdkAdditionalData implements SdkAdditionalData { @NonNls private static final String PATHS_REMOVED_BY_USER_ROOT = "PATHS_REMOVED_BY_USER_ROOT"; @NonNls private static final String PATH_REMOVED_BY_USER = "PATH_REMOVED_BY_USER"; @NonNls private static final String ASSOCIATED_PROJECT_PATH = "ASSOCIATED_PROJECT_PATH"; + @NonNls private static final String IS_PIPENV = "IS_PIPENV"; private final VirtualFilePointerContainer myAddedPaths; private final VirtualFilePointerContainer myExcludedPaths; @@ -54,6 +55,7 @@ public class PythonSdkAdditionalData implements SdkAdditionalData { private final PythonSdkFlavor myFlavor; private String myAssociatedProjectPath; private boolean myAssociateWithNewProject; + private boolean myIsPipEnv; public PythonSdkAdditionalData(@Nullable PythonSdkFlavor flavor) { myFlavor = flavor; @@ -112,12 +114,26 @@ public class PythonSdkAdditionalData implements SdkAdditionalData { } } + public boolean isPipEnv() { + return myIsPipEnv; + } + + public void setPipEnv(boolean pipEnv) { + myIsPipEnv = pipEnv; + } + public void save(@NotNull final Element rootElement) { savePaths(rootElement, myAddedPaths, PATHS_ADDED_BY_USER_ROOT, PATH_ADDED_BY_USER); savePaths(rootElement, myExcludedPaths, PATHS_REMOVED_BY_USER_ROOT, PATH_REMOVED_BY_USER); if (myAssociatedProjectPath != null) { rootElement.setAttribute(ASSOCIATED_PROJECT_PATH, myAssociatedProjectPath); + // XXX: We have to persist the pipenv flag since pipenv is no different from a regular + // virtualenv and currently we want to handle pipenvs differently. Consider adding an SDK + // extension mechanism for that + if (myIsPipEnv) { + rootElement.setAttribute(IS_PIPENV, "true"); + } } } @@ -148,6 +164,7 @@ public class PythonSdkAdditionalData implements SdkAdditionalData { collectPaths(JDOMExternalizer.loadStringsList(element, PATHS_REMOVED_BY_USER_ROOT, PATH_REMOVED_BY_USER),myExcludedPaths); if (element != null) { data.setAssociatedProjectPath(element.getAttributeValue(ASSOCIATED_PROJECT_PATH)); + data.setPipEnv("true".equals(element.getAttributeValue(IS_PIPENV))); } } diff --git a/python/src/com/jetbrains/python/sdk/add/PyAddNewCondaEnvPanel.kt b/python/src/com/jetbrains/python/sdk/add/PyAddNewCondaEnvPanel.kt index 18125a1c0b65..b26129d702f8 100644 --- a/python/src/com/jetbrains/python/sdk/add/PyAddNewCondaEnvPanel.kt +++ b/python/src/com/jetbrains/python/sdk/add/PyAddNewCondaEnvPanel.kt @@ -118,7 +118,7 @@ class PyAddNewCondaEnvPanel(private val project: Project?, } val shared = makeSharedField.isSelected val associatedPath = if (!shared) newProjectPath ?: project?.basePath else null - val sdk = createSdkByGenerateTask(task, existingSdks, null, associatedPath) ?: return null + val sdk = createSdkByGenerateTask(task, existingSdks, null, associatedPath, null) ?: return null if (!shared) { sdk.associateWithProject(project, newProjectPath != null) } diff --git a/python/src/com/jetbrains/python/sdk/add/PyAddNewVirtualEnvPanel.kt b/python/src/com/jetbrains/python/sdk/add/PyAddNewVirtualEnvPanel.kt index 483c7c49c7f4..ddd8fad4a566 100644 --- a/python/src/com/jetbrains/python/sdk/add/PyAddNewVirtualEnvPanel.kt +++ b/python/src/com/jetbrains/python/sdk/add/PyAddNewVirtualEnvPanel.kt @@ -107,7 +107,7 @@ class PyAddNewVirtualEnvPanel(private val project: Project?, } val shared = makeSharedField.isSelected val associatedPath = if (!shared) newProjectPath ?: project?.basePath else null - val sdk = createSdkByGenerateTask(task, existingSdks, baseSdkField.selectedSdk, associatedPath) ?: return null + val sdk = createSdkByGenerateTask(task, existingSdks, baseSdkField.selectedSdk, associatedPath, null) ?: return null if (!shared) { sdk.associateWithProject(project, newProjectPath != null) } diff --git a/python/src/com/jetbrains/python/sdk/add/PyAddPipEnvPanel.kt b/python/src/com/jetbrains/python/sdk/add/PyAddPipEnvPanel.kt new file mode 100644 index 000000000000..d9625a433f17 --- /dev/null +++ b/python/src/com/jetbrains/python/sdk/add/PyAddPipEnvPanel.kt @@ -0,0 +1,135 @@ +// 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.add + +import com.intellij.execution.ExecutionException +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.ValidationInfo +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.vfs.StandardFileSystems +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.components.JBCheckBox +import com.intellij.util.PathUtil +import com.intellij.util.ui.FormBuilder +import com.jetbrains.python.psi.LanguageLevel +import com.jetbrains.python.sdk.PythonSdkType +import com.jetbrains.python.sdk.associateWithProject +import com.jetbrains.python.sdk.associatedProjectPath +import com.jetbrains.python.sdk.createSdkByGenerateTask +import com.jetbrains.python.sdk.flavors.* +import java.awt.BorderLayout +import javax.swing.Icon +import javax.swing.JComboBox + +/** + * The UI panel for adding the pipenv interpreter for the project. + * + * TODO: Pass a module here, since pipenv interpreters are per module, not per project. + * + * @author vlan + */ +class PyAddPipEnvPanel(private val project: Project?, + private val existingSdks: List, + override var newProjectPath: String?) : PyAddNewEnvPanel() { + override val envName = "Pipenv" + override val panelName = "Pipenv Environment" + override val icon: Icon = PIPENV_ICON + + private val DEFAULT_PYTHON = "Default" + private val languageLevelsField: JComboBox + private val installPackagesCheckBox = JBCheckBox("Install packages from Pipfile").apply { + isEnabled = projectPipFile != null + } + + init { + layout = BorderLayout() + + val supportedLanguageLevels = + listOf(DEFAULT_PYTHON, + LanguageLevel.PYTHON37, + LanguageLevel.PYTHON36, + LanguageLevel.PYTHON35, + LanguageLevel.PYTHON34, + LanguageLevel.PYTHON27) + .map { it.toString() } + + languageLevelsField = ComboBox(supportedLanguageLevels.toTypedArray()).apply { + selectedItem = if (itemCount > 0) getItemAt(0) else null + } + + val formPanel = FormBuilder.createFormBuilder() + .addLabeledComponent("Python version:", languageLevelsField) + .addComponent(installPackagesCheckBox) + .panel + add(formPanel, BorderLayout.NORTH) + } + + override fun getOrCreateSdk(): Sdk? { + // TODO: Pass a module here for selecting the proper Pipfile? + val path = projectPath ?: return null + val task = object : Task.WithResult(project, "Configuring Pipenv Environment", true) { + override fun compute(indicator: ProgressIndicator): String { + indicator.isIndeterminate = true + val pipEnv = setupPipEnv(FileUtil.toSystemDependentName(path), + selectedLanguageLevel, + installPackagesCheckBox.isSelected) + return PythonSdkType.getPythonExecutable(pipEnv) ?: FileUtil.join(pipEnv, "bin", "python") + } + } + val suggestedName = "Pipenv (${PathUtil.getFileName(path)})" + return createSdkByGenerateTask(task, existingSdks, null, path, suggestedName)?.apply { + isPipEnv = true + associateWithProject(project, newProjectPath != null) + } + } + + override fun validateAll(): List = + listOfNotNull(validatePipEnvExecutable(), validatePipEnvIsNotAdded()) + + /** + * Checks if `pipenv` is available on `$PATH`. + */ + private fun validatePipEnvExecutable(): ValidationInfo? = + when (getPipEnvExecutable()) { + null -> ValidationInfo("Pipenv executable is not found on \$PATH") + else -> null + } + + /** + * Checks if the pipenv for the project hasn't been already added. + */ + private fun validatePipEnvIsNotAdded(): ValidationInfo? { + val path = projectPath ?: return null + val addedPipEnv = existingSdks.find { + it.associatedProjectPath == path && it.isPipEnv + } ?: return null + return ValidationInfo("""Pipenv interpreter has been already added, select "${addedPipEnv.name}" in your interpreters list""") + } + + /** + * The effective project path for the new project or for the existing project. + */ + private val projectPath: String? + get() = newProjectPath ?: project?.basePath + + /** + * The version of Python selected by the user or `null` if the default version is selected. + */ + private val selectedLanguageLevel: String? + get() { + val text = languageLevelsField.getItemAt(languageLevelsField.selectedIndex) + return if (text == DEFAULT_PYTHON) null else text + } + + /** + * The Pipfile for the effective project path. + */ + private val projectPipFile: VirtualFile? + get() { + val path = projectPath ?: return null + return StandardFileSystems.local().findFileByPath(path)?.findChild(PIP_FILE) + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/sdk/add/PyAddSdkDialog.kt b/python/src/com/jetbrains/python/sdk/add/PyAddSdkDialog.kt index 73ce387df205..0afcaca2e660 100644 --- a/python/src/com/jetbrains/python/sdk/add/PyAddSdkDialog.kt +++ b/python/src/com/jetbrains/python/sdk/add/PyAddSdkDialog.kt @@ -73,6 +73,7 @@ class PyAddSdkDialog private constructor(private val project: Project?, .filter { it.sdkType is PythonSdkType && !PythonSdkType.isInvalid(it) } .sortedWith(PreferredSdkComparator()) val panels = arrayListOf(createVirtualEnvPanel(project, sdks, newProjectPath), + createPipEnvPanel(), createAnacondaPanel(project), PyAddSystemWideInterpreterPanel(existingSdks)) val extendedPanels = PyAddSdkProvider.EP_NAME.extensions @@ -230,6 +231,10 @@ class PyAddSdkDialog private constructor(private val project: Project?, return PyAddSdkGroupPanel("Conda environment", PythonIcons.Python.Anaconda, panels, panels[0]) } + private fun createPipEnvPanel(): PyAddSdkPanel { + return PyAddPipEnvPanel(project, existingSdks, newProjectPath) + } + /** * Navigates to the next step of the current wizard view. */ diff --git a/python/src/com/jetbrains/python/sdk/flavors/pipenv.kt b/python/src/com/jetbrains/python/sdk/flavors/pipenv.kt new file mode 100644 index 000000000000..2ac0d4715eb2 --- /dev/null +++ b/python/src/com/jetbrains/python/sdk/flavors/pipenv.kt @@ -0,0 +1,89 @@ +// 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.flavors + +import com.intellij.execution.RunCanceledByUserException +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.configurations.PathEnvironmentVariableUtil +import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.execution.process.ProcessOutput +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.projectRoots.Sdk +import com.jetbrains.python.packaging.PyExecutionException +import com.jetbrains.python.sdk.PythonSdkAdditionalData +import com.jetbrains.python.sdk.getOrCreateAdditionalData +import icons.PythonIcons +import org.jetbrains.annotations.SystemDependent +import java.io.File +import javax.swing.Icon + +/** + * @author vlan + */ + +const val PIP_FILE: String = "Pipfile" +// TODO: Provide a special icon for pipenv +val PIPENV_ICON: Icon = PythonIcons.Python.PythonClosed + +/** + * Tells if the SDK was added as a pipenv. + */ +var Sdk.isPipEnv: Boolean + get() = (sdkAdditionalData as? PythonSdkAdditionalData)?.isPipEnv ?: false + set(value) { + getOrCreateAdditionalData().isPipEnv = value + } + +/** + * Finds the pipenv executable in `$PATH`. + */ +fun getPipEnvExecutable(): File? = + PathEnvironmentVariableUtil.findInPath("pipenv") + +/** + * Sets up the pipenv environment for the specified project path. + * + * @return the path to the pipenv environment. + */ +fun setupPipEnv(projectPath: @SystemDependent String, python: String?, installPackages: Boolean): @SystemDependent String { + when { + installPackages -> { + val pythonArgs = if (python != null) listOf("--python", python) else emptyList() + val command = pythonArgs + listOf("install", "--dev") + runPipEnv(projectPath, *command.toTypedArray()) + } + python != null -> + runPipEnv(projectPath, "--python", python) + else -> + runPipEnv(projectPath, "run", "python", "-V") + } + return runPipEnv(projectPath, "--venv").trim() +} + +/** + * Runs the configured pipenv for the specified project path. + */ +fun runPipEnv(projectPath: @SystemDependent String, vararg args: String): String { + val executable = getPipEnvExecutable()?.path ?: + throw PyExecutionException("Cannot find pipenv", "pipenv", emptyList(), ProcessOutput()) + + val command = listOf(executable) + args + val commandLine = GeneralCommandLine(command).withWorkDirectory(projectPath) + val handler = CapturingProcessHandler(commandLine) + val indicator = ProgressManager.getInstance().progressIndicator + val result = if (indicator != null) { + handler.runProcessWithProgressIndicator(indicator) + } + else { + handler.runProcess() + } + with(result) { + return when { + isCancelled -> + throw RunCanceledByUserException() + exitCode != 0 -> + throw PyExecutionException("Cannot run Python from pipenv", executable, args.asList(), + stdout, stderr, exitCode, emptyList()) + else -> stdout + } + } +}