From aa5acb75c90f22d0607b3203e9e815fb1554430c Mon Sep 17 00:00:00 2001 From: Semyon Proshev Date: Mon, 6 Dec 2021 19:40:53 +0300 Subject: [PATCH] Ability to install python from PyCharm on macOS (PY-51686) GitOrigin-RevId: 53ba94d3de3844fb5b58822576e4a54fe060d53b --- .../messages/PyBundle.properties | 11 ++- .../jetbrains/python/sdk/PySdkToInstall.kt | 92 ++++++++++++++++++- .../jetbrains/python/sdk/add/PyAddSdkPanel.kt | 5 + .../sdk/flavors/MacPythonSdkFlavor.java | 74 ++++++++++++++- 4 files changed, 179 insertions(+), 3 deletions(-) diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties index 94e55fc92d52..ea7402aaf269 100644 --- a/python/pluginResources/messages/PyBundle.properties +++ b/python/pluginResources/messages/PyBundle.properties @@ -432,8 +432,10 @@ python.sdk.can.t.obtain.python.version=Can't obtain python version python.sdk.empty.version.string=Python interpreter returned the empty output as a version string python.sdk.non.zero.exit.code=Python interpreter process exited with a non-zero exit code {0} python.sdk.executable.not.found.header=Python executable is not found. Choose one of the following options: -python.sdk.executable.not.found.option.specify.path=Click {0} to specify a path to python.exe in your file system +python.sdk.executable.not.found.option.specify.path=Click {0} to specify a path to {1} in your file system python.sdk.executable.not.found.option.download.and.install=Click {0} to download and install Python from python.org ({1}) +python.sdk.executable.not.found.option.install.with.cldt=Click {0} to install Python with the Command-Line Developer Tools: {1} +python.sdk.executable.not.found.option.install.or.brew=Install Python manually from python.org or via Homebrew python.sdk.admin.permissions.needed.consider.creating.venv=Note: You'll need admin permissions to install packages for this interpreter. Consider creating a per-project virtual environment instead. python.sdk.try.to.install.python.manually=Try to install Python from https://www.python.org manually. python.sdk.consider.installing.python.manually=Consider installing Python from https://www.python.org manually. @@ -449,6 +451,13 @@ python.sdk.action.add.new.interpreter.text=Add New Interpreter python.sdk.action.add.local.interpreter.text=Add Local Interpreter... python.sdk.action.add.interpreter.based.on.target.text=On {0}... +python.cldt.checking=Checking for Command-Line Developer Tools installation +python.cldt.required=Selected python requires Command-Line Developer Tools: {0} +python.cldt.installing.suggestion=Install Python with the Command-Line Developer Tools +python.cldt.installing.title=Waiting for Python Installation and Interpreter Setup +python.cldt.installing.indicator=macOS is installing Command-Line Developer Tools +python.cldt.installing.skip=Skip this step to configure an interpreter later + # SDK / skeletons sdk.some.skeletons.failed=Some skeletons failed to generate sdk.gen.cleaning.up=Cleaning up skeletons\u2026 diff --git a/python/src/com/jetbrains/python/sdk/PySdkToInstall.kt b/python/src/com/jetbrains/python/sdk/PySdkToInstall.kt index cede3f058ac6..52808f67dc11 100644 --- a/python/src/com/jetbrains/python/sdk/PySdkToInstall.kt +++ b/python/src/com/jetbrains/python/sdk/PySdkToInstall.kt @@ -7,8 +7,11 @@ import com.google.common.io.Files import com.intellij.execution.ExecutionException import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.execution.process.OSProcessUtil import com.intellij.execution.process.ProcessOutput +import com.intellij.execution.util.ExecUtil.execAndGetOutput import com.intellij.icons.AllIcons +import com.intellij.ide.IdeBundle import com.intellij.openapi.application.PathManager import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.diagnostic.debug @@ -38,9 +41,11 @@ import com.jetbrains.python.sdk.PySdkToInstallCollector.Companion.InstallationRe import com.jetbrains.python.sdk.PySdkToInstallCollector.Companion.LookupResult import com.jetbrains.python.sdk.PySdkToInstallCollector.Companion.logSdkDownload import com.jetbrains.python.sdk.PySdkToInstallCollector.Companion.logSdkInstallation +import com.jetbrains.python.sdk.flavors.MacPythonSdkFlavor import org.jetbrains.annotations.CalledInAny import java.io.File import java.io.IOException +import java.util.concurrent.TimeUnit import kotlin.math.absoluteValue private val LOGGER = Logger.getInstance(PySdkToInstall::class.java) @@ -48,6 +53,7 @@ private val LOGGER = Logger.getInstance(PySdkToInstall::class.java) @CalledInAny internal fun getSdksToInstall(): List { return if (SystemInfo.isWindows) listOf(getPy39ToInstallOnWindows(), getPy310ToInstallOnWindows()) + else if (SystemInfo.isMac) listOf(PySdkToInstallViaXCodeSelect()) else emptyList() } @@ -127,7 +133,7 @@ private class PySdkToInstallOnWindows(name: String, return HtmlBuilder() .append(PyBundle.message("python.sdk.executable.not.found.header")) .append(tag("ul").children( - tag("li").children(raw(PyBundle.message("python.sdk.executable.not.found.option.specify.path", text("...").bold()))), + tag("li").children(raw(PyBundle.message("python.sdk.executable.not.found.option.specify.path", text("...").bold(), "python.exe"))), tag("li").children(raw(PyBundle.message("python.sdk.executable.not.found.option.download.and.install", text(defaultButtonName).bold(), fileSize))) )).toString() @@ -331,3 +337,87 @@ private class PySdkToInstallOnWindows(name: String, private class PyInstallationException(val commandLine: GeneralCommandLine, val output: ProcessOutput) : Exception() private class PyInstallationExecutionException(val commandLine: GeneralCommandLine, override val cause: ExecutionException) : Exception() } + +private class PySdkToInstallViaXCodeSelect : PySdkToInstall("Python", "") { + + override fun renderInList(renderer: PySdkListCellRenderer) { + renderer.append(name) + renderer.append(" ") + renderer.append(PyBundle.message("python.cldt.installing.suggestion"), SimpleTextAttributes.GRAYED_SMALL_ATTRIBUTES) + } + + @NlsContexts.DialogMessage + override fun getInstallationWarning(defaultButtonName: String): String { + val commandChunk = text(MacPythonSdkFlavor.getXCodeSelectInstallCommand().commandLineString) + + return HtmlBuilder() + .append(PyBundle.message("python.sdk.executable.not.found.header")) + .append(tag("ul").children( + tag("li").children(raw(PyBundle.message("python.sdk.executable.not.found.option.specify.path", text("...").bold(), "python"))), + tag("li").children(raw( + PyBundle.message("python.sdk.executable.not.found.option.install.with.cldt", text(defaultButtonName).bold(), commandChunk.code()) + )), + tag("li").children(text(PyBundle.message("python.sdk.executable.not.found.option.install.or.brew"))) + )).toString() + } + + override fun install(module: Module?, systemWideSdksDetector: () -> List): PyDetectedSdk? { + val project = module?.project + return ProgressManager.getInstance().run( + object : Task.WithResult(project, PyBundle.message("python.cldt.installing.title"), true) { + override fun compute(indicator: ProgressIndicator): PyDetectedSdk? { + @Suppress("DialogTitleCapitalization") + indicator.text = PyBundle.message("python.cldt.installing.indicator") + indicator.text2 = PyBundle.message("python.cldt.installing.skip") + + runXCodeSelectInstall() + + while (!MacPythonSdkFlavor.areCommandLineDeveloperToolsAvailable() && isInstallCommandLineDeveloperToolsAppRunning()) { + Thread.sleep(TimeUnit.SECONDS.toMillis(5)) + } + + LOGGER.debug("Resetting system-wide sdks detectors") + resetSystemWideSdksDetectors() + + return systemWideSdksDetector() + .also { sdks -> + LOGGER.debug { sdks.joinToString(prefix = "Detected system-wide sdks: ") { it.homePath ?: it.name } } + } + .singleOrNull() + } + } + .also { + it.cancelText = IdeBundle.message("button.skip") + it.cancelTooltipText = IdeBundle.message("button.skip") + } + ) + } + + private fun runXCodeSelectInstall() { + val commandLine = MacPythonSdkFlavor.getXCodeSelectInstallCommand() + try { + execAndGetOutput(commandLine) + .also { + if (LOGGER.isDebugEnabled) { + LOGGER.debug("Result of '${commandLine.commandLineString}':\n$it") + } + } + } + catch (e: ExecutionException) { + LOGGER.warn("Exception during '${commandLine.commandLineString}'", e) + } + } + + private fun isInstallCommandLineDeveloperToolsAppRunning(): Boolean { + val appName = "Install Command Line Developer Tools.app" + + return OSProcessUtil + .getProcessList() + .any { it.commandLine.contains(appName) } + .also { + if (LOGGER.isDebugEnabled) { + LOGGER.debug("'$appName' is${if (it) "" else " not"} running") + } + } + } +} diff --git a/python/src/com/jetbrains/python/sdk/add/PyAddSdkPanel.kt b/python/src/com/jetbrains/python/sdk/add/PyAddSdkPanel.kt index 7b82a1b36d62..7febce549a33 100644 --- a/python/src/com/jetbrains/python/sdk/add/PyAddSdkPanel.kt +++ b/python/src/com/jetbrains/python/sdk/add/PyAddSdkPanel.kt @@ -25,6 +25,7 @@ import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.ui.TextFieldWithBrowseButton import com.intellij.openapi.ui.ValidationInfo import com.intellij.openapi.util.NlsContexts +import com.intellij.openapi.util.SystemInfo import com.intellij.openapi.util.UserDataHolder import com.intellij.openapi.util.text.StringUtil import com.jetbrains.python.PySdkBundle @@ -32,6 +33,7 @@ import com.jetbrains.python.newProject.steps.PyAddNewEnvironmentPanel import com.jetbrains.python.sdk.* import com.jetbrains.python.sdk.add.PyAddSdkDialogFlowAction.OK import com.jetbrains.python.sdk.configuration.PyProjectVirtualEnvConfiguration +import com.jetbrains.python.sdk.flavors.MacPythonSdkFlavor import icons.PythonIcons import java.awt.Component import java.io.File @@ -104,6 +106,9 @@ abstract class PyAddSdkPanel : JPanel(), PyAddSdkView { val message = sdk.getInstallationWarning(defaultButtonName) ValidationInfo(message).asWarning().withOKEnabled() } + is PyDetectedSdk -> { + if (SystemInfo.isMac) MacPythonSdkFlavor.checkDetectedPython(sdk) else null + } else -> null } } diff --git a/python/src/com/jetbrains/python/sdk/flavors/MacPythonSdkFlavor.java b/python/src/com/jetbrains/python/sdk/flavors/MacPythonSdkFlavor.java index 80ebab22558e..dee0cb505cef 100644 --- a/python/src/com/jetbrains/python/sdk/flavors/MacPythonSdkFlavor.java +++ b/python/src/com/jetbrains/python/sdk/flavors/MacPythonSdkFlavor.java @@ -1,12 +1,23 @@ // Copyright 2000-2021 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.ExecutionException; +import com.intellij.execution.configurations.GeneralCommandLine; +import com.intellij.execution.process.ProcessOutput; +import com.intellij.execution.util.ExecUtil; +import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.module.Module; +import com.intellij.openapi.progress.ProgressManager; +import com.intellij.openapi.ui.ValidationInfo; import com.intellij.openapi.util.SystemInfo; import com.intellij.openapi.util.UserDataHolder; +import com.intellij.openapi.util.text.HtmlBuilder; +import com.intellij.openapi.util.text.HtmlChunk; import com.intellij.openapi.vfs.LocalFileSystem; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.openapi.vfs.newvfs.NewVirtualFile; +import com.jetbrains.python.PyBundle; +import com.jetbrains.python.sdk.PyDetectedSdk; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -14,8 +25,14 @@ import java.util.Collection; import java.util.HashSet; import java.util.Set; +import static com.intellij.openapi.util.text.HtmlChunk.raw; +import static com.intellij.openapi.util.text.HtmlChunk.text; + public final class MacPythonSdkFlavor extends CPythonSdkFlavor { + + private static final Logger LOGGER = Logger.getInstance(MacPythonSdkFlavor.class); + private MacPythonSdkFlavor() { } @@ -32,7 +49,9 @@ public final class MacPythonSdkFlavor extends CPythonSdkFlavor { collectPythonInstallations("/System/Library/Frameworks/Python.framework/Versions", candidates); collectPythonInstallations("/usr/local/Cellar/python", candidates); UnixPythonSdkFlavor.collectUnixPythons("/usr/local/bin", candidates); - UnixPythonSdkFlavor.collectUnixPythons("/usr/bin", candidates); + if (areCommandLineDeveloperToolsAvailable()) { + UnixPythonSdkFlavor.collectUnixPythons("/usr/bin", candidates); + } return candidates; } @@ -56,4 +75,57 @@ public final class MacPythonSdkFlavor extends CPythonSdkFlavor { } } } + + public static @NotNull GeneralCommandLine getXCodeSelectInstallCommand() { + return new GeneralCommandLine("xcode-select", "--install"); + } + + private static @NotNull GeneralCommandLine getXCodeSelectPathCommand() { + return new GeneralCommandLine("xcode-select", "-p"); + } + + public static boolean areCommandLineDeveloperToolsAvailable() { + final GeneralCommandLine commandLine = getXCodeSelectPathCommand(); + + try { + final ProcessOutput output = ExecUtil.execAndGetOutput(commandLine); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Result of '" + commandLine.getCommandLineString() + "':\n" + output); + } + + return output.getExitCode() != 2; + } + catch (ExecutionException e) { + LOGGER.warn("Exception during '" + commandLine.getCommandLineString() + "'", e); + return true; + } + } + + public static @Nullable ValidationInfo checkDetectedPython(@NotNull PyDetectedSdk sdk) { + if (!"/usr/bin/python3".equals(sdk.getHomePath())) return null; + + //noinspection DialogTitleCapitalization + final String progressTitle = PyBundle.message("python.cldt.checking"); + + if (ProgressManager + .getInstance() + .runProcessWithProgressSynchronously( + MacPythonSdkFlavor::areCommandLineDeveloperToolsAvailable, + progressTitle, + true, + null + ) + ) { + return null; + } + + final HtmlChunk commandChunk = text(getXCodeSelectInstallCommand().getCommandLineString()); + + final String message = new HtmlBuilder().append( + raw(PyBundle.message("python.cldt.required", commandChunk.code())) + ).toString(); + + return new ValidationInfo(message).asWarning().withOKEnabled(); + } }