From 1b87f861c67e77cea99ca55e8a69d8338a7c67b6 Mon Sep 17 00:00:00 2001 From: Aleksei Kniazev Date: Sun, 16 Oct 2022 23:49:17 +0200 Subject: [PATCH] [python]: new API for package management and conda support (PY-49324) - new entry point to python package management: PythonPackageManager - new internal package representation entities - target-based process running to support remote interpreters - initial support for the new api on Python Packages toolwindow - initial support for conda on the toolwindow - new cache for PyPI packages, caches for python/conda packages - Python Packages toolwindow and service refactoring - coroutine-based PyPI cache update on startup GitOrigin-RevId: 167c32229eb669c5348ba930b35008bee9e51914 --- python/intellij.python.community.impl.iml | 1 + .../messages/PyBundle.properties | 23 +- python/src/META-INF/python-core-common.xml | 11 + .../jetbrains/extensions/PythonProjectExt.kt | 14 + .../python/packaging/PyPIPackageRanking.kt | 26 +- .../python/packaging/PyPackagesUpdater.java | 6 - .../packaging/cache/PythonPackageCache.kt | 11 + .../cache/PythonSimpleRepositoryCache.kt | 91 +++++ .../python/packaging/common/packages.kt | 81 ++++ .../jetbrains/python/packaging/common/util.kt | 44 +++ .../packaging/conda/CondaPackageCache.kt | 90 +++++ .../packaging/conda/CondaPackageManager.kt | 105 +++++ .../CondaPackagingToolwindowActionProvider.kt | 22 ++ .../packaging/conda/CondaRepositoryManger.kt | 51 +++ .../python/packaging/conda/common.kt | 37 ++ .../management/PythonPackageManager.kt | 32 ++ .../management/PythonPackageManagerExt.kt | 109 ++++++ .../management/PythonPackagesUpdater.kt | 39 ++ .../management/PythonRepositoryManager.kt | 28 ++ .../packaging/pip/PipBasedPackageManager.kt | 30 ++ .../pip/PipBasedRepositoryManager.kt | 121 ++++++ .../PipPackagingToolwindowActionProvider.kt | 23 ++ .../packaging/pip/PipPythonPackageManager.kt | 41 ++ .../packaging/pip/PipRepositoryManager.kt | 9 + .../python/packaging/pip/PypiPackageCache.kt | 97 +++++ .../repository/PyPackageRepository.kt | 9 +- .../repository/PyPackageRepositoryUtil.kt | 3 +- .../toolwindow/PyPackagesToolWindowFactory.kt | 2 +- .../toolwindow/PyPackagingTablesView.kt | 51 +-- .../toolwindow/PyPackagingToolWindowPanel.kt | 158 +++++--- .../PyPackagingToolWindowService.kt | 368 +++++------------- .../python/packaging/toolwindow/actions.kt | 31 ++ .../packaging/toolwindow/modelComponents.kt | 7 +- .../packaging/toolwindow/uiComponents.kt | 12 +- .../python/sdk/PythonSdkUpdater.java | 6 + .../python/sdk/flavors/conda/PyCondaEnv.kt | 18 + 36 files changed, 1420 insertions(+), 387 deletions(-) create mode 100644 python/src/com/jetbrains/extensions/PythonProjectExt.kt create mode 100644 python/src/com/jetbrains/python/packaging/cache/PythonPackageCache.kt create mode 100644 python/src/com/jetbrains/python/packaging/cache/PythonSimpleRepositoryCache.kt create mode 100644 python/src/com/jetbrains/python/packaging/common/packages.kt create mode 100644 python/src/com/jetbrains/python/packaging/common/util.kt create mode 100644 python/src/com/jetbrains/python/packaging/conda/CondaPackageCache.kt create mode 100644 python/src/com/jetbrains/python/packaging/conda/CondaPackageManager.kt create mode 100644 python/src/com/jetbrains/python/packaging/conda/CondaPackagingToolwindowActionProvider.kt create mode 100644 python/src/com/jetbrains/python/packaging/conda/CondaRepositoryManger.kt create mode 100644 python/src/com/jetbrains/python/packaging/conda/common.kt create mode 100644 python/src/com/jetbrains/python/packaging/management/PythonPackageManager.kt create mode 100644 python/src/com/jetbrains/python/packaging/management/PythonPackageManagerExt.kt create mode 100644 python/src/com/jetbrains/python/packaging/management/PythonPackagesUpdater.kt create mode 100644 python/src/com/jetbrains/python/packaging/management/PythonRepositoryManager.kt create mode 100644 python/src/com/jetbrains/python/packaging/pip/PipBasedPackageManager.kt create mode 100644 python/src/com/jetbrains/python/packaging/pip/PipBasedRepositoryManager.kt create mode 100644 python/src/com/jetbrains/python/packaging/pip/PipPackagingToolwindowActionProvider.kt create mode 100644 python/src/com/jetbrains/python/packaging/pip/PipPythonPackageManager.kt create mode 100644 python/src/com/jetbrains/python/packaging/pip/PipRepositoryManager.kt create mode 100644 python/src/com/jetbrains/python/packaging/pip/PypiPackageCache.kt create mode 100644 python/src/com/jetbrains/python/packaging/toolwindow/actions.kt diff --git a/python/intellij.python.community.impl.iml b/python/intellij.python.community.impl.iml index 58aab2a53ac6..f28d30771660 100644 --- a/python/intellij.python.community.impl.iml +++ b/python/intellij.python.community.impl.iml @@ -110,6 +110,7 @@ + diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties index 1f876a4bc90c..f597e8de4697 100644 --- a/python/pluginResources/messages/PyBundle.properties +++ b/python/pluginResources/messages/PyBundle.properties @@ -1217,6 +1217,24 @@ python.packaging.repository.form.password=Password: python.packaging.repository.form.default.name=Package Repository python.packaging.loading.packages.progress.text=Loading packages\u2026 notification.group.packaging=Python packaging +notification.group.packaging.toolwindow=Python Packages +python.packaging.install.progress=Installing package {0} +python.packaging.uninstall.progress=Uninstalling package {0} +python.packaging.list.progress=Reading installed python packages +python.packaging.could.not.parse.response=

Could not parse the response for the package {0} from repository {1}

+python.packaging.notification.installed=Package {0} installed +python.packaging.notification.deleted=Package {0} deleted +python.packaging.button.install.package=Install package +python.packaging.no.package.info=

Could not read package information

+python.packages.request.failed=

Request failed.

+ +# Conda +conda.packaging.install.progress=Installing conda package {0} +conda.packaging.uninstall.progress=Uninstalling conda package {0} +conda.packaging.list.progress=Reading installed conda packages +conda.packaging.cache.update.progress=Updating available conda packages list +conda.packaging.button.install.with.conda=Install with conda +conda.packaging.button.install.with.pip=Install with pip # Python Packages toolwindow python.toolwindow.packages.installed.label=Installed @@ -1225,16 +1243,11 @@ python.toolwindow.packages.custom.repo.invalid={0} (Authorization failed) python.toolwindow.packages.documentation.link=Documentation python.toolwindow.packages.no.interpreter.text=Select an interpreter to see the installed packages python.toolwindow.packages.latest.version.label=latest -python.toolwindow.packages.install.button=Install python.toolwindow.packages.delete.package=Delete Package python.toolwindow.packages.search.text.placeholder=Search for more packages python.toolwindow.packages.description.panel.placeholder=Select a package to view documentation -python.toolwindow.packages.request.failed=

Request failed.

python.toolwindow.packages.no.description.placeholder=

Package author did not provide a description.

-python.toolwindow.packages.remote.interpreter.placeholder=

Local interpreter is required to view package documentation.

-python.toolwindow.packages.collecting.packages.task.title=Collecting packages python.toolwindow.packages.rst.parsing.failed=Failed to parse the description -python.toolwindow.packages.no.documentation=No documentation found in current repository python.toolwindow.packages.manage.repositories.action=Manage repositories python.toolwindow.packages.reload.repositories.action=Reload all repositories python.toolwindow.packages.add.package.action=Add Package diff --git a/python/src/META-INF/python-core-common.xml b/python/src/META-INF/python-core-common.xml index b2042d1bb2a8..3a5412f6ffa8 100644 --- a/python/src/META-INF/python-core-common.xml +++ b/python/src/META-INF/python-core-common.xml @@ -483,6 +483,8 @@ + @@ -498,6 +500,8 @@ + + @@ -590,6 +594,10 @@ + + @@ -685,6 +693,9 @@ + + + diff --git a/python/src/com/jetbrains/extensions/PythonProjectExt.kt b/python/src/com/jetbrains/extensions/PythonProjectExt.kt new file mode 100644 index 000000000000..e36fc75df919 --- /dev/null +++ b/python/src/com/jetbrains/extensions/PythonProjectExt.kt @@ -0,0 +1,14 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +@file:JvmName("PythonProjectExt") + +package com.jetbrains.extensions + +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.modules +import com.jetbrains.python.sdk.PythonSdkType +import com.jetbrains.python.sdk.PythonSdkUtil + +val Project.hasPython: Boolean + get() = modules.asSequence() + .map { PythonSdkUtil.findPythonSdk(it) } + .any { it != null && it.sdkType is PythonSdkType } \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/PyPIPackageRanking.kt b/python/src/com/jetbrains/python/packaging/PyPIPackageRanking.kt index 9d307cca6892..c3b4f57bd8ab 100644 --- a/python/src/com/jetbrains/python/packaging/PyPIPackageRanking.kt +++ b/python/src/com/jetbrains/python/packaging/PyPIPackageRanking.kt @@ -3,7 +3,8 @@ package com.jetbrains.python.packaging import com.google.common.io.Resources import com.google.gson.Gson -import com.intellij.openapi.application.ApplicationManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.util.* import java.util.concurrent.locks.ReentrantReadWriteLock import kotlin.concurrent.read @@ -22,16 +23,19 @@ object PyPIPackageRanking { val names: Sequence get() = myPackageRank.asSequence().map { it.key } - fun reload() { - ApplicationManager.getApplication().assertIsNonDispatchThread(); - val gson = Gson() - val resource = PyPIPackageRanking::class.java.getResource("/packaging/pypi-ranking.json") ?: error("Python package ranking not found") - val array = Resources.asCharSource(resource, Charsets.UTF_8).openBufferedStream().use { - gson.fromJson(it, Array>::class.java) + suspend fun reload() { + withContext(Dispatchers.IO) { + val gson = Gson() + val resource = PyPIPackageRanking::class.java.getResource("/packaging/pypi-ranking.json") ?: error("Python package ranking not found") + val array = Resources.asCharSource(resource, Charsets.UTF_8).openBufferedStream().use { + gson.fromJson(it, Array>::class.java) + } + val newRanked = array.asSequence() + .map { Pair(it[0].lowercase(), it[1].toInt()) } + .toMap(LinkedHashMap()) + withContext(Dispatchers.Main) { + myPackageRank = Collections.unmodifiableMap(newRanked) + } } - val newRanked = array.asSequence() - .map { Pair(it[0].toLowerCase(), it[1].toInt()) } - .toMap(LinkedHashMap()) - myPackageRank = Collections.unmodifiableMap(newRanked) } } \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/PyPackagesUpdater.java b/python/src/com/jetbrains/python/packaging/PyPackagesUpdater.java index 9b75c34999b6..e9aefcec6beb 100644 --- a/python/src/com/jetbrains/python/packaging/PyPackagesUpdater.java +++ b/python/src/com/jetbrains/python/packaging/PyPackagesUpdater.java @@ -44,12 +44,6 @@ public class PyPackagesUpdater implements StartupActivity.Background { @Override public void runActivity(@NotNull Project project) { if (ApplicationManager.getApplication().isUnitTestMode()) return; - try { - PyPIPackageRanking.INSTANCE.reload(); - } - catch (Exception e) { - LOG.warn(e); - } if (!checkNeeded(project)) return; diff --git a/python/src/com/jetbrains/python/packaging/cache/PythonPackageCache.kt b/python/src/com/jetbrains/python/packaging/cache/PythonPackageCache.kt new file mode 100644 index 000000000000..6a855a684ca7 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/cache/PythonPackageCache.kt @@ -0,0 +1,11 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.cache + +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +interface PythonPackageCache { + val packages: List + operator fun contains(key: K): Boolean + fun isEmpty(): Boolean +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/cache/PythonSimpleRepositoryCache.kt b/python/src/com/jetbrains/python/packaging/cache/PythonSimpleRepositoryCache.kt new file mode 100644 index 000000000000..fc3024c90c68 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/cache/PythonSimpleRepositoryCache.kt @@ -0,0 +1,91 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.cache + +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.application.ApplicationNamesInfo +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.util.io.HttpRequests +import com.jetbrains.python.packaging.repository.PyPackageRepositories +import com.jetbrains.python.packaging.repository.PyPackageRepository +import com.jetbrains.python.packaging.repository.withBasicAuthorization +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.annotations.ApiStatus +import javax.swing.text.MutableAttributeSet +import javax.swing.text.html.HTML +import javax.swing.text.html.HTMLEditorKit +import javax.swing.text.html.parser.ParserDelegator + +@ApiStatus.Experimental +object PythonSimpleRepositoryCache : PythonPackageCache { + + private var cache: Map> = emptyMap() + + val repositories: List + get() = cache.keys.toList() + override val packages: List + get() = cache.values.asSequence().flatten().toList() + + private val userAgent: String + get() = "${ApplicationNamesInfo.getInstance().productName}/${ApplicationInfo.getInstance().fullVersion}" + + suspend fun refresh() { + val service = service() + withContext(Dispatchers.IO) { + val newCache = mutableMapOf>() + service.repositories.forEach { + try { + newCache[it] = loadFrom(it) + } + catch (ex: Exception) { + thisLogger().error("could not refresh repository ${it.repositoryUrl}") + service.markInvalid(it.repositoryUrl!!) + } + } + withContext(Dispatchers.Main) { + cache = newCache + } + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun loadFrom(repository: PyPackageRepository): List { + return withContext(Dispatchers.IO) { + val packages = mutableListOf() + HttpRequests.request(repository.repositoryUrl!!) + .userAgent(userAgent) + .withBasicAuthorization(repository) + .connect { request -> + ParserDelegator().parse(request.reader, object : HTMLEditorKit.ParserCallback() { + var myTag: HTML.Tag? = null + override fun handleStartTag(tag: HTML.Tag, set: MutableAttributeSet, i: Int) { + myTag = tag + } + + override fun handleText(data: CharArray, pos: Int) { + if ("a" == myTag?.toString()) { + var packageName = String(data) + if (packageName.endsWith("/")) { + packageName = packageName.substring(0, packageName.indexOf("/")) + } + packages.add(packageName) + } + } + + override fun handleEndTag(t: HTML.Tag, pos: Int) { + myTag = null + } + }, true) + + } + packages + } + } + + operator fun get(key: PyPackageRepository): List? = cache[key] + + override fun isEmpty(): Boolean = cache.isEmpty() + + override fun contains(key: PyPackageRepository): Boolean = key in cache +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/common/packages.kt b/python/src/com/jetbrains/python/packaging/common/packages.kt new file mode 100644 index 000000000000..7ba2107ffc33 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/common/packages.kt @@ -0,0 +1,81 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.common + +import com.jetbrains.python.packaging.repository.PyEmptyPackagePackageRepository +import com.jetbrains.python.packaging.repository.PyPackageRepository +import org.jetbrains.annotations.Nls + +open class PythonPackage(val name: String, val version: String) + +interface PythonPackageDetails { + + val name: String + val availableVersions: List + val repository: PyPackageRepository + val summary: String? + val description: String? + val descriptionContentType: String? + val documentationUrl: String? + fun toPackageSpecification(version: String? = null): PythonPackageSpecification +} + +data class PythonSimplePackageDetails( + override val name: String, + override val availableVersions: List = emptyList(), + override val repository: PyPackageRepository, + override val summary: String? = null, + @Nls override val description: String? = null, + override val descriptionContentType: String? = null, + override val documentationUrl: String? = null) : PythonPackageDetails { + + override fun toPackageSpecification(version: String?): PythonSimplePackageSpecification { + return PythonSimplePackageSpecification(name, version, repository) + } +} + +class EmptyPythonPackageDetails(override val name: String, @Nls override val description: String? = null) : PythonPackageDetails { + override val availableVersions: List = emptyList() + override val repository: PyPackageRepository = PyEmptyPackagePackageRepository + override val summary: String? = null + override val descriptionContentType: String? = null + override val documentationUrl: String? = null + override fun toPackageSpecification(version: String?) = error("Using EmptyPythonPackageDetails for specification") +} + +interface PythonPackageSpecification { + // todo[akniazev]: add version specs and use them in buildInstallationString + val name: String + val version: String? + val repository: PyPackageRepository? + + fun buildInstallationString(): List { + return listOf("$name${if (version != null) "==$version" else ""}") + } +} + +interface PythonLocationBasedPackageSpecification : PythonPackageSpecification { + val location: String + val editable: Boolean + val prefix: String + override val version: String? + get() = null + override val repository: PyPackageRepository? + get() = null + override fun buildInstallationString(): List = if (editable) listOf("-e", "$prefix$location") else listOf("$prefix$location") +} + +data class PythonSimplePackageSpecification(override val name: String, + override val version: String?, + override val repository: PyPackageRepository?) : PythonPackageSpecification + +data class PythonLocalPackageSpecification(override val name: String, + override val location: String, + override val editable: Boolean) : PythonLocationBasedPackageSpecification { + override val prefix: String = "file://" +} + +data class PythonVcsPackageSpecification(override val name: String, + override val location: String, + override val prefix: String, + override val editable: Boolean) : PythonLocationBasedPackageSpecification + diff --git a/python/src/com/jetbrains/python/packaging/common/util.kt b/python/src/com/jetbrains/python/packaging/common/util.kt new file mode 100644 index 000000000000..7da3f9a2e10d --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/common/util.kt @@ -0,0 +1,44 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.common + +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.jetbrains.python.packaging.PyPIPackageRanking +import com.jetbrains.python.packaging.conda.CondaPackageManager +import com.jetbrains.python.packaging.management.PythonPackageManager +import com.jetbrains.python.packaging.pip.PipPythonPackageManager +import com.jetbrains.python.sdk.PythonSdkUtil +import org.jetbrains.annotations.ApiStatus + +object PackageManagerHolder { + private val cache = mutableMapOf() + + fun forSdk(project: Project, sdk: Sdk): PythonPackageManager? { + if (sdk.homePath in cache) return cache[sdk.homePath] // todo[akniazev] replace with sdk key + + val manager = when { + PythonSdkUtil.isConda(sdk) -> CondaPackageManager(project, sdk) // todo[akniazev] extract to an extension point + else -> PipPythonPackageManager(project, sdk) + } + cache[sdk.homePath!!] = manager + + return manager + } +} + +@ApiStatus.Experimental +interface PythonPackageManagementListener { + fun packagesChanged(sdk: Sdk) +} + +internal val RANKING_AWARE_PACKAGE_NAME_COMPARATOR: java.util.Comparator = Comparator { name1, name2 -> + val ranking = PyPIPackageRanking.packageRank + val rank1 = ranking[name1.lowercase()] + val rank2 = ranking[name2.lowercase()] + return@Comparator when { + rank1 != null && rank2 == null -> -1 + rank1 == null && rank2 != null -> 1 + rank1 != null && rank2 != null -> rank2 - rank1 + else -> String.CASE_INSENSITIVE_ORDER.compare(name1, name2) + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/conda/CondaPackageCache.kt b/python/src/com/jetbrains/python/packaging/conda/CondaPackageCache.kt new file mode 100644 index 000000000000..809c5ff26a13 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/conda/CondaPackageCache.kt @@ -0,0 +1,90 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.conda + +import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.execution.target.TargetProgressIndicator +import com.intellij.execution.target.TargetedCommandLineBuilder +import com.intellij.execution.target.local.LocalTargetEnvironmentRequest +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.progress.withBackgroundProgressIndicator +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.util.text.StringUtil +import com.jetbrains.python.PyBundle +import com.jetbrains.python.packaging.cache.PythonPackageCache +import com.jetbrains.python.packaging.common.RANKING_AWARE_PACKAGE_NAME_COMPARATOR +import com.jetbrains.python.run.PythonInterpreterTargetEnvironmentFactory +import com.jetbrains.python.sdk.flavors.conda.PyCondaCommand +import com.jetbrains.python.sdk.flavors.conda.PyCondaEnv +import com.jetbrains.python.sdk.flavors.conda.PyCondaEnvIdentity +import com.jetbrains.python.sdk.flavors.conda.PyCondaFlavorData +import com.jetbrains.python.sdk.getOrCreateAdditionalData +import com.jetbrains.python.sdk.targetEnvConfiguration +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +object CondaPackageCache : PythonPackageCache { + private var cache: Map> = emptyMap() + + override val packages: List + get() = cache.keys.toList() + + suspend fun refreshAll(sdk: Sdk, project: Project) { + withContext(Dispatchers.IO) { + val pathOnTarget = (sdk.getOrCreateAdditionalData().flavorAndData.data as PyCondaFlavorData).env.fullCondaPathOnTarget + val targetConfig = sdk.targetEnvConfiguration + + val command = PyCondaCommand(pathOnTarget, targetConfig, project) + val baseConda = PyCondaEnv.getEnvs(command).getOrThrow() + .first { it.envIdentity is PyCondaEnvIdentity.UnnamedEnv && it.envIdentity.isBase } + + val helpersAware = PythonInterpreterTargetEnvironmentFactory.findPythonTargetInterpreter(sdk, project) + val helpers = helpersAware.preparePyCharmHelpers() + + + val targetReq = targetConfig?.createEnvironmentRequest(project) ?: LocalTargetEnvironmentRequest() + val commandLineBuilder = TargetedCommandLineBuilder(targetReq) + val targetEnv = targetReq.prepareEnvironment(TargetProgressIndicator.EMPTY) + + + val helpersPath = helpers.apply(targetEnv) + + baseConda.addCondaToTargetBuilder(commandLineBuilder) + + commandLineBuilder.addParameter("python") + commandLineBuilder.addParameter("$helpersPath/conda_packaging_tool.py") + commandLineBuilder.addParameter("listall") + + val targetedCommandLine = commandLineBuilder.build() + + val process = targetEnv.createProcess(targetedCommandLine) + + + val commandLine = targetedCommandLine.collectCommandsSynchronously() + val commandLineString = StringUtil.join(commandLine, " ") + val handler = CapturingProcessHandler(process, targetedCommandLine.charset, commandLineString) + thisLogger().debug("Running conda packaging tool to read available conda packages") + val result = withBackgroundProgressIndicator(project, PyBundle.message("conda.packaging.cache.update.progress"), cancellable = true) { + handler.runProcess(10 * 60 * 1000) + } + result.checkSuccess(thisLogger()) + + val packages = result.stdout.lineSequence() + .map { it.split("\t") } + .filterNot { it.size < 2 } + .filterNot { it[0].startsWith("r-") } // todo[akniazev]: make sure it's the best way to get rid of R packages + .groupBy({ it[0] }, { it[1] }) + .toSortedMap(RANKING_AWARE_PACKAGE_NAME_COMPARATOR) + + withContext(Dispatchers.Main) { + cache = packages + } + } + } + + override fun isEmpty(): Boolean = cache.isEmpty() + operator fun get(name: String): List? = cache[name] + override fun contains(key: String): Boolean = key in cache +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/conda/CondaPackageManager.kt b/python/src/com/jetbrains/python/packaging/conda/CondaPackageManager.kt new file mode 100644 index 000000000000..5a3282770585 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/conda/CondaPackageManager.kt @@ -0,0 +1,105 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.conda + +import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.execution.target.TargetProgressIndicator +import com.intellij.execution.target.TargetedCommandLineBuilder +import com.intellij.execution.target.local.LocalTargetEnvironmentRequest +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.progress.withBackgroundProgressIndicator +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.util.text.StringUtil +import com.jetbrains.python.PyBundle +import com.jetbrains.python.packaging.pip.PipBasedPackageManager +import com.jetbrains.python.packaging.common.PythonPackage +import com.jetbrains.python.packaging.common.PythonPackageSpecification +import com.jetbrains.python.sdk.flavors.conda.PyCondaFlavorData +import com.jetbrains.python.sdk.getOrCreateAdditionalData +import com.jetbrains.python.sdk.targetEnvConfiguration +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.annotations.Nls + +@ApiStatus.Experimental +class CondaPackageManager(project: Project, sdk: Sdk) : PipBasedPackageManager(project, sdk) { + + override var installedPackages: List = emptyList() + private set + + override val repositoryManager: CondaRepositoryManger = CondaRepositoryManger(project, sdk) + + override suspend fun installPackage(specification: PythonPackageSpecification) { + if (specification is CondaPackageSpecification) withContext(Dispatchers.IO) { + runConda("install", specification.buildInstallationString() + "-y", PyBundle.message("conda.packaging.install.progress", specification.name)) + reloadPackages() + } + else super.installPackage(specification) + + } + + override suspend fun uninstallPackage(pkg: PythonPackage) { + if (pkg is CondaPackage && !pkg.installedWithPip) withContext(Dispatchers.IO) { + runConda("uninstall", listOf(pkg.name, "-y"), PyBundle.message("conda.packaging.uninstall.progress", pkg.name)) + reloadPackages() + } + else super.uninstallPackage(pkg) + } + + override suspend fun reloadPackages() { + withContext(Dispatchers.IO) { + val result = runConda("list", emptyList(), PyBundle.message("conda.packaging.list.progress")) + val packages = parseCondaPackageList(result) + + withContext(Dispatchers.Main) { + installedPackages = packages + } + + ApplicationManager.getApplication() + .messageBus + .syncPublisher(PACKAGE_MANAGEMENT_TOPIC) + .packagesChanged(sdk) + } + } + + private fun parseCondaPackageList(text: String): List { + return text.lineSequence() + .filterNot { it.startsWith("#") } + .map { line -> line.split("\\s+".toRegex()) } + .filterNot { it.size < 2 } + .map { CondaPackage(it[0], it[1], installedWithPip = (it.size >= 4 && it[3] == "pypi")) } + .toList() + } + + + private suspend fun runConda(operation: String, arguments: List, @Nls text: String): String { + return withContext(Dispatchers.IO) { + val targetConfig = sdk.targetEnvConfiguration + val targetReq = targetConfig?.createEnvironmentRequest(project) ?: LocalTargetEnvironmentRequest() + val commandLineBuilder = TargetedCommandLineBuilder(targetReq) + val targetEnv = targetReq.prepareEnvironment(TargetProgressIndicator.EMPTY) + val env = (sdk.getOrCreateAdditionalData().flavorAndData.data as PyCondaFlavorData).env + + commandLineBuilder.setExePath(env.fullCondaPathOnTarget) + commandLineBuilder.addParameter(operation) + env.addCondaEnvironmentToTargetBuilder(commandLineBuilder) + arguments.forEach(commandLineBuilder::addParameter) + + val targetedCommandLine = commandLineBuilder.build() + val process = targetEnv.createProcess(targetedCommandLine) + val commandLine = targetedCommandLine.collectCommandsSynchronously() + val commandLineString = StringUtil.join(commandLine, " ") + val handler = CapturingProcessHandler(process, targetedCommandLine.charset, commandLineString) + + val result = withBackgroundProgressIndicator(project, text, cancellable = true) { + handler.runProcess(10 * 60 * 1000) + } + + result.checkSuccess(thisLogger()) + result.stdout + } + } + +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/conda/CondaPackagingToolwindowActionProvider.kt b/python/src/com/jetbrains/python/packaging/conda/CondaPackagingToolwindowActionProvider.kt new file mode 100644 index 000000000000..4649f05352b1 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/conda/CondaPackagingToolwindowActionProvider.kt @@ -0,0 +1,22 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.conda + +import com.jetbrains.python.PyBundle +import com.jetbrains.python.packaging.common.PythonPackageDetails +import com.jetbrains.python.packaging.management.PythonPackageManager +import com.jetbrains.python.packaging.toolwindow.PythonPackagingToolwindowActionProvider +import com.jetbrains.python.packaging.toolwindow.PythonPackageInstallAction +import com.jetbrains.python.packaging.toolwindow.SimplePythonPackageInstallAction +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +class CondaPackagingToolwindowActionProvider : PythonPackagingToolwindowActionProvider { + override fun getInstallActions(details: PythonPackageDetails, packageManager: PythonPackageManager): List? { + if (packageManager is CondaPackageManager) { + return if (details is CondaPackageDetails) { + listOf(SimplePythonPackageInstallAction(PyBundle.message("conda.packaging.button.install.with.conda"), packageManager.project)) + } else listOf(SimplePythonPackageInstallAction(PyBundle.message("conda.packaging.button.install.with.pip"), packageManager.project)) + } + return null + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/conda/CondaRepositoryManger.kt b/python/src/com/jetbrains/python/packaging/conda/CondaRepositoryManger.kt new file mode 100644 index 000000000000..7f35c5068b37 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/conda/CondaRepositoryManger.kt @@ -0,0 +1,51 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.conda + +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.jetbrains.python.packaging.common.PythonPackageDetails +import com.jetbrains.python.packaging.common.PythonPackageSpecification +import com.jetbrains.python.packaging.pip.PipBasedRepositoryManager +import com.jetbrains.python.packaging.repository.PyPackageRepository +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +class CondaRepositoryManger(project: Project, sdk: Sdk) : PipBasedRepositoryManager(project, sdk) { + + override val repositories: List + get() = listOf(CondaPackageRepository) + super.repositories + + override fun allPackages(): List = CondaPackageCache.packages + + override fun packagesFromRepository(repository: PyPackageRepository): List { + return if (repository is CondaPackageRepository) CondaPackageCache.packages else super.packagesFromRepository(repository) + } + + override fun buildPackageDetails(rawInfo: String, spec: PythonPackageSpecification): PythonPackageDetails { + if (spec is CondaPackageSpecification) { + val detailsFromPyPI = super.buildPackageDetails(rawInfo, spec) + val versions = CondaPackageCache[spec.name] ?: error("No conda package versions in cache") + + return CondaPackageDetails(detailsFromPyPI.name, + versions, + detailsFromPyPI.summary, + detailsFromPyPI.description, + detailsFromPyPI.descriptionContentType, + detailsFromPyPI.documentationUrl) + + } + return super.buildPackageDetails(rawInfo, spec) + } + + override suspend fun initCaches() { + super.initCaches() + if (CondaPackageCache.isEmpty()) { + CondaPackageCache.refreshAll(sdk, project) + } + } + + override suspend fun refreshCashes() { + super.refreshCashes() + CondaPackageCache.refreshAll(sdk, project) + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/conda/common.kt b/python/src/com/jetbrains/python/packaging/conda/common.kt new file mode 100644 index 000000000000..640396f13de8 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/conda/common.kt @@ -0,0 +1,37 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.conda + +import com.jetbrains.python.packaging.common.PythonPackage +import com.jetbrains.python.packaging.common.PythonPackageDetails +import com.jetbrains.python.packaging.common.PythonPackageSpecification +import com.jetbrains.python.packaging.repository.PyPackageRepository + +class CondaPackage(name: String, version: String, val installedWithPip: Boolean = false) : PythonPackage(name, version) + + +class CondaPackageSpecification(override val name: String, + override val version: String?) : PythonPackageSpecification { + override val repository: PyPackageRepository = CondaPackageRepository + + override fun buildInstallationString(): List { + return listOf("$name${if (version != null) "=$version" else ""}") + } +} + +class CondaPackageDetails(override val name: String, + override val availableVersions: List = emptyList(), + override val summary: String? = null, + override val description: String? = null, + override val descriptionContentType: String? = null, + override val documentationUrl: String? = null) : PythonPackageDetails { + override val repository: PyPackageRepository = CondaPackageRepository + override fun toPackageSpecification(version: String?): PythonPackageSpecification { + return CondaPackageSpecification(name, version) + } +} + +object CondaPackageRepository : PyPackageRepository("Conda", "", "") { + override fun createPackageSpecification(packageName: String, version: String?): PythonPackageSpecification { + return CondaPackageSpecification(packageName, null) + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/management/PythonPackageManager.kt b/python/src/com/jetbrains/python/packaging/management/PythonPackageManager.kt new file mode 100644 index 000000000000..4720f3a4088e --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/management/PythonPackageManager.kt @@ -0,0 +1,32 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.management + +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.util.messages.Topic +import com.jetbrains.python.packaging.common.PackageManagerHolder +import com.jetbrains.python.packaging.common.PythonPackage +import com.jetbrains.python.packaging.common.PythonPackageManagementListener +import com.jetbrains.python.packaging.common.PythonPackageSpecification +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +abstract class PythonPackageManager(val project: Project, val sdk: Sdk) { + abstract val installedPackages: List + + abstract val repositoryManager: PythonRepositoryManager + + abstract suspend fun installPackage(specification: PythonPackageSpecification) + abstract suspend fun uninstallPackage(pkg: PythonPackage) + + abstract suspend fun reloadPackages() + + companion object { + fun forSdk(project: Project, sdk: Sdk): PythonPackageManager? { + return PackageManagerHolder.forSdk(project, sdk) + } + + @Topic.AppLevel + val PACKAGE_MANAGEMENT_TOPIC = Topic(PythonPackageManagementListener::class.java, Topic.BroadcastDirection.TO_DIRECT_CHILDREN) + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/management/PythonPackageManagerExt.kt b/python/src/com/jetbrains/python/packaging/management/PythonPackageManagerExt.kt new file mode 100644 index 000000000000..d81a8a2957e7 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/management/PythonPackageManagerExt.kt @@ -0,0 +1,109 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +@file:JvmName("PythonPackageManagerExt") +package com.jetbrains.python.packaging.management + +import com.intellij.execution.RunCanceledByUserException +import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.execution.target.TargetProgressIndicator +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.progress.EmptyProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.withBackgroundProgressIndicator +import com.intellij.openapi.util.text.StringUtil +import com.intellij.util.net.HttpConfigurable +import com.jetbrains.python.PySdkBundle +import com.jetbrains.python.PythonHelper +import com.jetbrains.python.packaging.PyExecutionException +import com.jetbrains.python.packaging.repository.PyPackageRepository +import com.jetbrains.python.run.PythonInterpreterTargetEnvironmentFactory +import com.jetbrains.python.run.buildTargetedCommandLine +import com.jetbrains.python.run.prepareHelperScriptExecution +import kotlinx.coroutines.launch +import org.jetbrains.annotations.Nls +import kotlin.math.min + +fun PythonPackageManager.launchReload() { + ApplicationManager.getApplication().coroutineScope.launch { + reloadPackages() + } +} + +suspend fun PythonPackageManager.runPackagingTool(operation: String, arguments: List, @Nls text: String): String { + // todo[akniazev]: check for package management tools + val helpersAwareTargetRequest = PythonInterpreterTargetEnvironmentFactory.findPythonTargetInterpreter(sdk, project) + val targetEnvironmentRequest = helpersAwareTargetRequest.targetEnvironmentRequest + val pythonExecution = prepareHelperScriptExecution(PythonHelper.PACKAGING_TOOL, helpersAwareTargetRequest) + + // todo[akniazev]: check applyWorkingDir: PyTargetEnvironmentPackageManager.java:133 + + pythonExecution.addParameter(operation) + + proxyString?.let { + pythonExecution.addParameter("--proxy") + pythonExecution.addParameter(it) + } + + arguments.forEach(pythonExecution::addParameter) + + // // todo[akniazev]: add extra args to package specification + + val targetProgressIndicator = TargetProgressIndicator.EMPTY + val targetEnvironment = targetEnvironmentRequest.prepareEnvironment(targetProgressIndicator) + + targetEnvironment.uploadVolumes.entries.forEach { (_, value) -> + value.upload(".", targetProgressIndicator) + } + + val targetedCommandLine = pythonExecution.buildTargetedCommandLine(targetEnvironment, sdk, emptyList()) + + val indicator = ProgressManager.getInstance().progressIndicator ?: EmptyProgressIndicator() + // from targets package manager + // TODO [targets] Apply environment variables: setPythonUnbuffered(...), setPythonDontWriteBytecode(...), resetHomePathChanges(...) + // TODO [targets] Apply flavor from PythonSdkFlavor.getFlavor(mySdk) + // TODO [targets] check askForSudo + + val process = targetEnvironment.createProcess(targetedCommandLine, indicator) + + val commandLine = targetedCommandLine.collectCommandsSynchronously() + val commandLineString = commandLine.joinToString(" ") + + thisLogger().debug("Running python packaging tool. Operation: $operation") + val handler = CapturingProcessHandler(process, targetedCommandLine.charset, commandLineString) + + val result = withBackgroundProgressIndicator(project, text, cancellable = true) { + handler.runProcess(10 * 60 * 1000) + } + + if (result.isCancelled) throw RunCanceledByUserException() + result.checkSuccess(thisLogger()) + val exitCode = result.exitCode + val helperPath = commandLine.firstOrNull() ?: "" + val args: List = commandLine.subList(min(1, commandLine.size), commandLine.size) + if (exitCode != 0) { + val message = if (StringUtil.isEmptyOrSpaces(result.stdout) && StringUtil.isEmptyOrSpaces(result.stderr)) PySdkBundle.message( + "python.conda.permission.denied") + else PySdkBundle.message("python.sdk.packaging.non.zero.exit.code", exitCode) + throw PyExecutionException(message, helperPath, args, result) + } + + if (result.isTimeout) { + throw PyExecutionException(PySdkBundle.message("python.sdk.packaging.timed.out"), helperPath, args, result) + } + + return result.stdout +} + +private val proxyString: String? + get() { + val settings = HttpConfigurable.getInstance() + if (settings != null && settings.USE_HTTP_PROXY) { + val credentials = if (settings.PROXY_AUTHENTICATION) "${settings.proxyLogin}:${settings.plainProxyPassword}@" else "" + return "http://$credentials${settings.PROXY_HOST}:${settings.PROXY_PORT}" + } + return null + } + +fun PythonRepositoryManager.packagesByRepository(): Sequence>> { + return repositories.asSequence().map { it to packagesFromRepository(it) } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/management/PythonPackagesUpdater.kt b/python/src/com/jetbrains/python/packaging/management/PythonPackagesUpdater.kt new file mode 100644 index 000000000000..645cb94da978 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/management/PythonPackagesUpdater.kt @@ -0,0 +1,39 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.management + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectPostStartupActivity +import com.intellij.util.io.exists +import com.jetbrains.extensions.hasPython +import com.jetbrains.python.packaging.PyPIPackageRanking +import com.jetbrains.python.packaging.pip.PypiPackageCache +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Files +import java.nio.file.Path +import java.time.Duration +import java.time.Instant + +class PythonPackagesUpdater : ProjectPostStartupActivity { + + override suspend fun execute(project: Project) { + if (ApplicationManager.getApplication().isUnitTestMode || !project.hasPython) return + withContext(Dispatchers.IO) { + thisLogger().debug("Updating PyPI cache and ranking") + PyPIPackageRanking.reload() + + if (PypiPackageCache.filePath.exists() && !cacheExpired(PypiPackageCache.filePath)) PypiPackageCache.loadFromFile() + else PypiPackageCache.refresh() + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + private fun cacheExpired(path: Path): Boolean { + val fileTime = Files.getLastModifiedTime(path) + val expirationTime = fileTime.toInstant().plus(Duration.ofDays(1)) + + return expirationTime.isBefore(Instant.now()) + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/management/PythonRepositoryManager.kt b/python/src/com/jetbrains/python/packaging/management/PythonRepositoryManager.kt new file mode 100644 index 000000000000..5828001bfe11 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/management/PythonRepositoryManager.kt @@ -0,0 +1,28 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.management + +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.jetbrains.python.packaging.repository.PyPackageRepository +import com.jetbrains.python.packaging.common.PythonPackageDetails +import com.jetbrains.python.packaging.common.PythonPackageSpecification +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +abstract class PythonRepositoryManager(val project: Project, val sdk: Sdk) { + + abstract val repositories: List + + abstract fun allPackages(): List + + abstract fun packagesFromRepository(repository: PyPackageRepository): List + suspend fun addRepository(repository: PyPackageRepository) { TODO() } + suspend fun removeRepository(repository: PyPackageRepository) { TODO() } + abstract suspend fun getPackageDetails(pkg: PythonPackageSpecification): PythonPackageDetails + + abstract suspend fun refreshCashes() + + abstract suspend fun initCaches() + + internal abstract fun buildPackageDetails(rawInfo: String, spec: PythonPackageSpecification): PythonPackageDetails +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/pip/PipBasedPackageManager.kt b/python/src/com/jetbrains/python/packaging/pip/PipBasedPackageManager.kt new file mode 100644 index 000000000000..4556bff4ed79 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/pip/PipBasedPackageManager.kt @@ -0,0 +1,30 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.pip + +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.jetbrains.python.PyBundle +import com.jetbrains.python.packaging.management.PythonPackageManager +import com.jetbrains.python.packaging.management.runPackagingTool +import com.jetbrains.python.packaging.common.PythonPackage +import com.jetbrains.python.packaging.common.PythonPackageSpecification +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +abstract class PipBasedPackageManager(project: Project, sdk: Sdk) : PythonPackageManager(project, sdk) { + override suspend fun installPackage(specification: PythonPackageSpecification) { + withContext(Dispatchers.IO) { + runPackagingTool("install", specification.buildInstallationString(), PyBundle.message("python.packaging.install.progress", specification.name)) + reloadPackages() + } + } + + override suspend fun uninstallPackage(pkg: PythonPackage) { + withContext(Dispatchers.IO) { + runPackagingTool("uninstall", listOf(pkg.name), PyBundle.message("python.packaging.uninstall.progress", pkg.name)) + reloadPackages() + } + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/pip/PipBasedRepositoryManager.kt b/python/src/com/jetbrains/python/packaging/pip/PipBasedRepositoryManager.kt new file mode 100644 index 000000000000..68523dfe8ae0 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/pip/PipBasedRepositoryManager.kt @@ -0,0 +1,121 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.pip + +import com.github.benmanes.caffeine.cache.Caffeine +import com.google.gson.Gson +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.util.text.StringUtil +import com.intellij.util.io.HttpRequests +import com.jetbrains.python.PyBundle +import com.jetbrains.python.packaging.PyPIPackageUtil +import com.jetbrains.python.packaging.PyPackageVersionComparator +import com.jetbrains.python.packaging.cache.PythonSimpleRepositoryCache +import com.jetbrains.python.packaging.common.EmptyPythonPackageDetails +import com.jetbrains.python.packaging.management.PythonRepositoryManager +import com.jetbrains.python.packaging.management.packagesByRepository +import com.jetbrains.python.packaging.repository.PyPIPackageRepository +import com.jetbrains.python.packaging.repository.PyPackageRepositories +import com.jetbrains.python.packaging.repository.PyPackageRepository +import com.jetbrains.python.packaging.repository.withBasicAuthorization +import com.jetbrains.python.packaging.common.PythonPackageDetails +import com.jetbrains.python.packaging.common.PythonPackageSpecification +import com.jetbrains.python.packaging.common.PythonSimplePackageDetails +import org.jetbrains.annotations.ApiStatus +import java.time.Duration + +@ApiStatus.Experimental +abstract class PipBasedRepositoryManager(project: Project, sdk: Sdk) : PythonRepositoryManager(project, sdk) { + + override val repositories: List + get() = listOf(PyPIPackageRepository) + PythonSimpleRepositoryCache.repositories + + private val gson = Gson() + private val packageDetailsCache = Caffeine.newBuilder() + .maximumSize(10) + .expireAfterWrite(Duration.ofHours(1)) + .build { + // todo[akniazev] make it possible to show info from several repos + val repositoryUrl = it.repository?.repositoryUrl ?: PyPIPackageRepository.repositoryUrl!! + val result = runCatching { + + val packageUrl = repositoryUrl.replace("simple", "pypi/${it.name}/json") + HttpRequests.request(packageUrl) + .withBasicAuthorization(it.repository) + .readTimeout(3000) + .readString() + } + if (result.isFailure) { + thisLogger().debug("Request failed for package $it.name") + + val versions = tryParsingVersionsFromPage(it.name, repositoryUrl) + if (versions != null) return@build PythonSimplePackageDetails(it.name, + versions.sortedWith(PyPackageVersionComparator.STR_COMPARATOR.reversed()), + it.repository!!, + description = PyBundle.message("python.packaging.no.package.info")) + else return@build EmptyPythonPackageDetails(it.name, PyBundle.message("python.packages.request.failed")) + } + + buildPackageDetails(result.getOrThrow(), it) + } + + + override fun buildPackageDetails(rawInfo: String, spec: PythonPackageSpecification): PythonPackageDetails { + try { + val packageDetails = gson.fromJson(rawInfo, PyPIPackageUtil.PackageDetails::class.java) + return PythonSimplePackageDetails(spec.name, + packageDetails.releases.sortedWith(PyPackageVersionComparator.STR_COMPARATOR.reversed()), + spec.repository!!, + packageDetails.info.summary, + packageDetails.info.description, + packageDetails.info.descriptionContentType, + packageDetails.info.projectUrls["Documentation"]) + + } + catch (ex: Exception) { + thisLogger().error(ex) + return EmptyPythonPackageDetails(spec.name, PyBundle.message("python.packaging.could.not.parse.response", spec.name, spec.repository?.name)) + } + } + + private fun tryParsingVersionsFromPage(name: String, repositoryUrl: String): List? { + val versions = runCatching { + val url = StringUtil.trimEnd(repositoryUrl, "/") + "/" + name + PyPIPackageUtil.parsePackageVersionsFromArchives(url, name) + } + return versions.getOrNull() + } + + + override suspend fun initCaches() { + if (PypiPackageCache.isEmpty()) { + PypiPackageCache.loadCache() + } + + val service = service() + if (service.repositories.isNotEmpty() && PythonSimpleRepositoryCache.isEmpty()) { + PythonSimpleRepositoryCache.refresh() + } + } + + override suspend fun refreshCashes() { + PypiPackageCache.refresh() + PythonSimpleRepositoryCache.refresh() + } + + override fun allPackages(): List { + // todo[akniazev] check if it is even needed + return packagesByRepository().flatMap { it.second }.distinct().toList() + } + + override fun packagesFromRepository(repository: PyPackageRepository): List { + return if (repository is PyPIPackageRepository) PypiPackageCache.packages + else PythonSimpleRepositoryCache[repository] ?: error("No packages for requested repository in cache") + } + + override suspend fun getPackageDetails(pkg: PythonPackageSpecification): PythonPackageDetails { + return packageDetailsCache[pkg] + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/pip/PipPackagingToolwindowActionProvider.kt b/python/src/com/jetbrains/python/packaging/pip/PipPackagingToolwindowActionProvider.kt new file mode 100644 index 000000000000..0ada465443eb --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/pip/PipPackagingToolwindowActionProvider.kt @@ -0,0 +1,23 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.pip + +import com.jetbrains.python.PyBundle +import com.jetbrains.python.packaging.common.PythonPackageDetails +import com.jetbrains.python.packaging.common.PythonSimplePackageDetails +import com.jetbrains.python.packaging.management.PythonPackageManager +import com.jetbrains.python.packaging.toolwindow.PythonPackageInstallAction +import com.jetbrains.python.packaging.toolwindow.PythonPackagingToolwindowActionProvider +import com.jetbrains.python.packaging.toolwindow.SimplePythonPackageInstallAction +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +class PipPackagingToolwindowActionProvider : PythonPackagingToolwindowActionProvider { + override fun getInstallActions(details: PythonPackageDetails, packageManager: PythonPackageManager): List? { + if (packageManager is PipPythonPackageManager && details is PythonSimplePackageDetails) + return listOf(SimplePythonPackageInstallAction(PyBundle.message("python.packaging.button.install.package"), packageManager.project)) + return null + } +} + + + diff --git a/python/src/com/jetbrains/python/packaging/pip/PipPythonPackageManager.kt b/python/src/com/jetbrains/python/packaging/pip/PipPythonPackageManager.kt new file mode 100644 index 000000000000..b6c9646f550e --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/pip/PipPythonPackageManager.kt @@ -0,0 +1,41 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.pip + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.jetbrains.python.PyBundle +import com.jetbrains.python.packaging.management.runPackagingTool +import com.jetbrains.python.packaging.common.PythonPackage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +class PipPythonPackageManager(project: Project, sdk: Sdk) : PipBasedPackageManager(project, sdk) { + override var installedPackages: List = emptyList() + private set + + override val repositoryManager: PipRepositoryManager = PipRepositoryManager(project, sdk) + + override suspend fun reloadPackages() { + withContext(Dispatchers.IO) { + val output = runPackagingTool("list", emptyList(), PyBundle.message("python.packaging.list.progress")) + + val packages = output.lines().filter { it.isNotBlank() }.map { + val line = it.split("\t") + PythonPackage(line[0], line[1]) + } + + withContext(Dispatchers.Main) { + installedPackages = packages + } + + ApplicationManager.getApplication() + .messageBus + .syncPublisher(PACKAGE_MANAGEMENT_TOPIC) + .packagesChanged(sdk) + } + } + +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/pip/PipRepositoryManager.kt b/python/src/com/jetbrains/python/packaging/pip/PipRepositoryManager.kt new file mode 100644 index 000000000000..d32daee1caf1 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/pip/PipRepositoryManager.kt @@ -0,0 +1,9 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.pip + +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +class PipRepositoryManager(project: Project, sdk: Sdk) : PipBasedRepositoryManager(project, sdk) \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/pip/PypiPackageCache.kt b/python/src/com/jetbrains/python/packaging/pip/PypiPackageCache.kt new file mode 100644 index 000000000000..8e266f4e6750 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/pip/PypiPackageCache.kt @@ -0,0 +1,97 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.pip + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.InstanceCreator +import com.google.gson.reflect.TypeToken +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.util.io.exists +import com.jetbrains.python.packaging.PyPIPackageUtil +import com.jetbrains.python.packaging.cache.PythonPackageCache +import com.jetbrains.python.packaging.common.RANKING_AWARE_PACKAGE_NAME_COMPARATOR +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.annotations.ApiStatus +import java.lang.reflect.Type +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.time.Duration +import java.time.Instant +import java.util.* + +@ApiStatus.Experimental +object PypiPackageCache : PythonPackageCache { + private val gson: Gson = GsonBuilder() + .registerTypeAdapter(object : TypeToken>() {}.type, + object : InstanceCreator> { + override fun createInstance(type: Type?): TreeSet { + return TreeSet(RANKING_AWARE_PACKAGE_NAME_COMPARATOR) + } + }) + .create() + + override val packages: List + get() = cache.toList() + + private var cache: TreeSet = TreeSet(RANKING_AWARE_PACKAGE_NAME_COMPARATOR) + + val filePath: Path + get() = Paths.get(PathManager.getSystemPath(), "python_packages", "packages_v2.json") + + suspend fun loadFromFile() { + withContext(Dispatchers.IO) { + val type = object : TypeToken>() {}.type + val newCache = Files.newBufferedReader(filePath, StandardCharsets.UTF_8).use { reader -> + val newCache: TreeSet = gson.fromJson(reader, type) + newCache + } + withContext(Dispatchers.Main) { + cache = newCache + } + } + } + + suspend fun store() { + withContext(Dispatchers.IO) { + Files.createDirectories(filePath.parent) + Files.newBufferedWriter(filePath, StandardCharsets.UTF_8).use { writer -> + gson.toJson(cache, writer) + } + } + } + + internal suspend fun loadCache() { + withContext(Dispatchers.IO) { + thisLogger().debug("Updating PyPI packages cache") + if (filePath.exists()) { + val fileTime = Files.getLastModifiedTime(filePath) + if (fileTime.toInstant().plus(Duration.ofDays(1)).isAfter(Instant.now())) { + thisLogger().debug("Cache file is not expired, reading packages locally") + loadFromFile() + return@withContext + } + thisLogger().debug("Cache expired, rebuilding it") + refresh() + return@withContext + } + thisLogger().debug("Cache file does not exist, reading packages from PyPI") + refresh() + } + } + + suspend fun refresh() { + cache.clear() + withContext(Dispatchers.IO) { + cache.addAll(PyPIPackageUtil.parsePyPIListFromWeb(PyPIPackageUtil.PYPI_LIST_URL)) + store() + } + } + + override operator fun contains(key: String): Boolean = key in cache + + override fun isEmpty(): Boolean = cache.isEmpty() +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/repository/PyPackageRepository.kt b/python/src/com/jetbrains/python/packaging/repository/PyPackageRepository.kt index 53c2e7208cb9..8245369cb6cd 100644 --- a/python/src/com/jetbrains/python/packaging/repository/PyPackageRepository.kt +++ b/python/src/com/jetbrains/python/packaging/repository/PyPackageRepository.kt @@ -7,9 +7,9 @@ import com.intellij.credentialStore.generateServiceName import com.intellij.ide.passwordSafe.PasswordSafe import com.intellij.openapi.components.BaseState import com.intellij.util.xmlb.annotations.Transient +import com.jetbrains.python.packaging.common.PythonPackageSpecification +import com.jetbrains.python.packaging.common.PythonSimplePackageSpecification import org.jetbrains.annotations.ApiStatus -import java.net.URLEncoder -import java.nio.charset.StandardCharsets @ApiStatus.Experimental open class PyPackageRepository() : BaseState() { @@ -57,4 +57,9 @@ open class PyPackageRepository() : BaseState() { this.repositoryUrl = repositoryUrl this.login = username } + + open fun createPackageSpecification(packageName: String, + version: String? = null): PythonPackageSpecification { + return PythonSimplePackageSpecification(packageName, version, this) + } } \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/repository/PyPackageRepositoryUtil.kt b/python/src/com/jetbrains/python/packaging/repository/PyPackageRepositoryUtil.kt index aab29870be10..db10b184343b 100644 --- a/python/src/com/jetbrains/python/packaging/repository/PyPackageRepositoryUtil.kt +++ b/python/src/com/jetbrains/python/packaging/repository/PyPackageRepositoryUtil.kt @@ -10,7 +10,8 @@ import java.nio.charset.StandardCharsets import java.util.* @ApiStatus.Experimental -internal fun RequestBuilder.withBasicAuthorization(repository: PyPackageRepository): RequestBuilder { +internal fun RequestBuilder.withBasicAuthorization(repository: PyPackageRepository?): RequestBuilder { + if (repository == null) return this val password = repository.getPassword() if (repository.login != null && password != null) { val credentials = Base64.getEncoder().encode("${repository.login}:${password}".toByteArray()).toString(StandardCharsets.UTF_8) diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagesToolWindowFactory.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagesToolWindowFactory.kt index 200ee34e8280..c6e1174944bc 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagesToolWindowFactory.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagesToolWindowFactory.kt @@ -11,7 +11,7 @@ import com.intellij.ui.content.ContentFactory class PyPackagesToolWindowFactory : ToolWindowFactory, DumbAware { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { val service = project.service() - val toolWindowPanel = PyPackagingToolWindowPanel(service, toolWindow) + val toolWindowPanel = PyPackagingToolWindowPanel(project, toolWindow) service.initialize(toolWindowPanel) val content = ContentFactory.getInstance().createContent(toolWindowPanel.component, null, false) toolWindow.contentManager.addContent(content) diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingTablesView.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingTablesView.kt index 26f2ecc21302..fe883598a031 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingTablesView.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingTablesView.kt @@ -2,6 +2,7 @@ package com.jetbrains.python.packaging.toolwindow import com.intellij.icons.AllIcons +import com.intellij.openapi.project.Project import com.intellij.ui.JBColor import com.jetbrains.python.PyBundle.message import com.jetbrains.python.packaging.repository.PyPackageRepository @@ -10,11 +11,13 @@ import javax.swing.JLabel import javax.swing.JPanel import javax.swing.JTable -class PyPackagingTablesView(private val service: PyPackagingToolWindowService, private val container: JPanel) { +class PyPackagingTablesView(private val project: Project, + private val container: JPanel, + private val controller: PyPackagingToolWindowPanel) { private val repositories: MutableList> = mutableListOf() private val installedPackages = PyPackagingTableGroup( object : PyPackageRepository(message("python.toolwindow.packages.installed.label"), "", "") {}, - PyPackagesTable(PyPackagesTableModel(), service, this)) + PyPackagesTable(project, PyPackagesTableModel(), this, controller)) private val invalidRepositories: MutableMap = mutableMapOf() init { installedPackages.addTo(container) @@ -31,6 +34,7 @@ class PyPackagingTablesView(private val service: PyPackagingToolWindowService, p table.expand() } + // todo[akniazev]: selecting a package in 'installed' list might make more sense tableToData .firstOrNull { (_, data) -> data.exactMatch != -1 } ?.let { selectPackage(it.second) } @@ -57,7 +61,7 @@ class PyPackagingTablesView(private val service: PyPackagingToolWindowService, p if (existingRepo != null) existingRepo.items = withExpander else { - val newTable = PyPackagesTable(PyPackagesTableModel(), service, this) + val newTable = PyPackagesTable(project, PyPackagesTableModel(), this, controller) newTable.items = withExpander val newTableGroup = PyPackagingTableGroup(data.repository, newTable) @@ -112,49 +116,8 @@ class PyPackagingTablesView(private val service: PyPackagingToolWindowService, p .forEach { it.table.clearSelection() } } - fun packagesAdded(newPackages: List) { - addInstalled(newPackages) - repositories.forEach { - val selectedRow = it.table.selectedRow - it.table.clearSelection() - newPackages.forEach { pkg -> - val index = it.table.items.indexOfFirst { item -> item.name == pkg.name } - if (index != -1) { - it.replace(index, pkg) - } - } - - if (selectedRow != -1) { - it.table.setRowSelectionInterval(selectedRow, selectedRow) - } - } - } - private fun findTableForRepo(repository: PyPackageRepository) = repositories.find { it.name == repository.name } - private fun addInstalled(newPackages: List) { - installedPackages.table.addRows(newPackages) - installedPackages.updateHeaderText(installedPackages.table.items.size) - } - - fun packageDeleted(deletedPackage: DisplayablePackage) { - val index = installedPackages.items.indexOfFirst { it.name == deletedPackage.name } - if (index != -1) { - installedPackages.table.removeRow(index) - installedPackages.itemsCount?.let { - installedPackages.updateHeaderText(it - 1) - } - } - - repositories.forEach { repo -> - val repoIndex = repo.items.indexOfFirst { it.name == deletedPackage.name } - if (repoIndex != -1) { - repo.replace(repoIndex, InstallablePackage(deletedPackage.name, deletedPackage.repository)) - } - } - - } - fun selectNextFrom(currentTable: JTable) { val targetGroup = when (currentTable) { installedPackages.table -> repositories.firstOrNull() diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowPanel.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowPanel.kt index 569f3ca59f76..768b4d8c18ab 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowPanel.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowPanel.kt @@ -3,10 +3,14 @@ package com.jetbrains.python.packaging.toolwindow import com.intellij.icons.AllIcons import com.intellij.ide.BrowserUtil +import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.* import com.intellij.openapi.actionSystem.impl.ActionButton +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.components.service import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.project.Project import com.intellij.openapi.ui.SimpleToolWindowPanel import com.intellij.openapi.ui.TextFieldWithBrowseButton import com.intellij.openapi.ui.popup.JBPopupFactory @@ -24,11 +28,19 @@ import com.intellij.ui.jcef.JCEFHtmlPanel import com.intellij.util.Alarm import com.intellij.util.Alarm.ThreadToUse import com.intellij.util.SingleAlarm +import com.intellij.util.childScope import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.intellij.util.ui.NamedColorUtil import com.intellij.util.ui.UIUtil import com.jetbrains.python.PyBundle.message +import com.jetbrains.python.packaging.common.PythonLocalPackageSpecification +import com.jetbrains.python.packaging.common.PythonPackageDetails +import com.jetbrains.python.packaging.common.PythonVcsPackageSpecification +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.awt.BorderLayout import java.awt.Component import java.awt.Dimension @@ -38,7 +50,12 @@ import java.awt.event.MouseEvent import javax.swing.* import javax.swing.event.DocumentEvent -class PyPackagingToolWindowPanel(service: PyPackagingToolWindowService, toolWindow: ToolWindow) : SimpleToolWindowPanel(false, true) { +class PyPackagingToolWindowPanel(private val project: Project, toolWindow: ToolWindow) : SimpleToolWindowPanel(false, true), Disposable { + private val packagingScope = ApplicationManager.getApplication().coroutineScope.childScope(Dispatchers.Default) + private var selectedPackage: DisplayablePackage? = null + private var selectedPackageDetails: PythonPackageDetails? = null + + // UI elements private val packageNameLabel = JLabel().apply { font = JBFont.h4().asBold(); isVisible = false } private val versionLabel = JLabel().apply { isVisible = false } private val documentationLink = HyperlinkLabel(message("python.toolwindow.packages.documentation.link")).apply { @@ -53,7 +70,6 @@ class PyPackagingToolWindowPanel(service: PyPackagingToolWindowService, toolWind private val progressBar: JProgressBar private val versionSelector: JBComboBoxLabel private val descriptionPanel: JCEFHtmlPanel - private var currentPackageInfo: PackageInfo? = null private var documentationUrl: String? = null private val packageListPanel: JPanel @@ -81,6 +97,8 @@ class PyPackagingToolWindowPanel(service: PyPackagingToolWindowService, toolWind init { + val service = project.service() + Disposer.register(service, this) withEmptyText(message("python.toolwindow.packages.no.interpreter.text")) descriptionPanel = PyPackagingJcefHtmlPanel(service.project) Disposer.register(toolWindow.disposable, descriptionPanel) @@ -90,7 +108,7 @@ class PyPackagingToolWindowPanel(service: PyPackagingToolWindowService, toolWind isVisible = false addMouseListener(object : MouseAdapter() { override fun mouseClicked(e: MouseEvent?) { - val versions = listOf(latestText) + (currentPackageInfo?.availableVersions ?: emptyList()) + val versions = listOf(latestText) + (selectedPackageDetails?.availableVersions ?: emptyList()) JBPopupFactory.getInstance().createListPopup( object : BaseListPopupStep(null, versions) { override fun onChosen(@NlsContexts.Label selectedValue: String, finalChoice: Boolean): PopupStep<*>? { @@ -102,20 +120,22 @@ class PyPackagingToolWindowPanel(service: PyPackagingToolWindowService, toolWind }) } - // todo: add options to install with args - installButton = JBOptionButton(object : AbstractAction(message("python.toolwindow.packages.install.button")) { - override fun actionPerformed(e: ActionEvent?) { - val version = if (versionSelector.text == latestText) null else versionSelector.text - service.installSelectedPackage(version) - } - }, null).apply { isVisible = false } - + // todo[akniazev]: add options to install with args + installButton = JBOptionButton(null, null).apply { isVisible = false } val uninstallToolbar = ActionManager.getInstance() .createActionToolbar(ActionPlaces.TOOLWINDOW_CONTENT, DefaultActionGroup(DefaultActionGroup().apply { add(object : AnAction(message("python.toolwindow.packages.delete.package")) { override fun actionPerformed(e: AnActionEvent) { - service.deleteSelectedPackage() + if (selectedPackage is InstalledPackage) { + packagingScope.launch(Dispatchers.Main) { + startProgress() + withContext(Dispatchers.IO) { + service.deletePackage(selectedPackage as InstalledPackage) + } + stopProgress() + } + } else error("Trying to delete package, that is not InstalledPackage") } }) isPopup = true @@ -124,7 +144,7 @@ class PyPackagingToolWindowPanel(service: PyPackagingToolWindowService, toolWind icon = AllIcons.Actions.More } }), true) - uninstallToolbar.setTargetComponent(this) + uninstallToolbar.targetComponent = this uninstallAction = uninstallToolbar.component progressBar = JProgressBar(JProgressBar.HORIZONTAL).apply { @@ -141,7 +161,7 @@ class PyPackagingToolWindowPanel(service: PyPackagingToolWindowService, toolWind background = UIUtil.getListBackground() } - tablesView = PyPackagingTablesView(service, packageListPanel) + tablesView = PyPackagingTablesView(project, packageListPanel, this) leftPanel = ScrollPaneFactory.createScrollPane(packageListPanel, true) rightPanel = borderPanel { @@ -219,18 +239,20 @@ class PyPackagingToolWindowPanel(service: PyPackagingToolWindowService, toolWind } }) val actionToolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.TOOLWINDOW_CONTENT, actionGroup,true) - actionToolbar.setTargetComponent(this) + actionToolbar.targetComponent = this val installFromLocationLink = DropDownLink(message("python.toolwindow.packages.add.package.action"), listOf(fromVcsText, fromDiscText)) { - val params = when (it) { + val specification = when (it) { fromDiscText -> showInstallFromDiscDialog(service) fromVcsText -> showInstallFromVcsDialog(service) else -> throw IllegalStateException("Unknown operation") } - if (params != null) { - service.installFromLocation(params.first, params.second) + if (specification != null) { + packagingScope.launch { + service.installPackage(specification) + } } } @@ -241,7 +263,7 @@ class PyPackagingToolWindowPanel(service: PyPackagingToolWindowService, toolWind minimumSize = Dimension(minimumSize.width, 30) maximumSize = Dimension(maximumSize.width, 30) add(searchTextField) - actionToolbar.component.maximumSize = Dimension(60, actionToolbar.component.maximumSize.height) + actionToolbar.component.maximumSize = Dimension(70, actionToolbar.component.maximumSize.height) add(actionToolbar.component) add(installFromLocationLink) } @@ -251,7 +273,7 @@ class PyPackagingToolWindowPanel(service: PyPackagingToolWindowService, toolWind setContent(mainPanel!!) } - private fun showInstallFromVcsDialog(service: PyPackagingToolWindowService): Pair? { + private fun showInstallFromVcsDialog(service: PyPackagingToolWindowService): PythonVcsPackageSpecification? { var editable = false var link = "" val systems = listOf(message("python.toolwindow.packages.add.package.vcs.git"), @@ -284,12 +306,13 @@ class PyPackagingToolWindowPanel(service: PyPackagingToolWindowService, toolWind message("python.toolwindow.packages.add.package.vcs.bzr") -> "bzr+" else -> throw IllegalStateException("Unknown VCS") } - return Pair(prefix + link, editable) + + return PythonVcsPackageSpecification(link, link, prefix, editable) } return null } - private fun showInstallFromDiscDialog(service: PyPackagingToolWindowService): Pair? { + private fun showInstallFromDiscDialog(service: PyPackagingToolWindowService): PythonLocalPackageSpecification? { var editable = false val textField = TextFieldWithBrowseButton().apply { @@ -309,7 +332,7 @@ class PyPackagingToolWindowPanel(service: PyPackagingToolWindowService, toolWind } val shouldInstall = dialog(message("python.toolwindow.packages.add.package.dialog.title"), panel, project = service.project, resizable = true).showAndGet() - return if (shouldInstall) Pair("file://${textField.text}", editable) else null + return if (shouldInstall) PythonLocalPackageSpecification(textField.text, textField.text, editable) else null } private fun trackOrientation(service: PyPackagingToolWindowService) { @@ -330,6 +353,64 @@ class PyPackagingToolWindowPanel(service: PyPackagingToolWindowService, toolWind }) } + + + fun packageSelected(selectedPackage: DisplayablePackage) { + showHeaderForPackage(selectedPackage) + this.selectedPackage = selectedPackage + packagingScope.launch(Dispatchers.IO) { + val service = project.service() + val packageDetails = service.detailsForPackage(selectedPackage) + + val installActions = PythonPackagingToolwindowActionProvider.EP_NAME + .extensionList + .firstNotNullOf { + it.getInstallActions(packageDetails, service.manager) + } + + withContext(Dispatchers.Main) { + selectedPackageDetails = packageDetails + + if (splitter?.secondComponent != rightPanel) { + splitter!!.secondComponent = rightPanel + } + + val renderedDescription = with(packageDetails) { + when { + !description.isNullOrEmpty() -> service.convertToHTML(descriptionContentType, description!!) + !summary.isNullOrEmpty() -> service.wrapHtml(summary!!) + else -> NO_DESCRIPTION + } + } + + descriptionPanel.setHtml(renderedDescription) + documentationUrl = packageDetails.documentationUrl + documentationLink.isVisible = documentationUrl != null + + + installButton.action = wrapAction(installActions.first(), packageDetails) + if (installActions.size > 1) { + installButton.options = installActions + .asSequence().drop(1).map { wrapAction(it, packageDetails) }.toList().toTypedArray() + } + + installButton.repaint() + } + } + } + + private fun wrapAction(installAction: PythonPackageInstallAction, details: PythonPackageDetails): Action { + return object : AbstractAction(installAction.text) { + override fun actionPerformed(e: ActionEvent?) { + val version = if (versionSelector.text == latestText) null else versionSelector.text + packagingScope.launch(Dispatchers.IO) { + val specification = details.toPackageSpecification(version) + installAction.installPackage(specification) + } + } + } + } + fun showSearchResult(installed: List, repoData: List) { tablesView.showSearchResult(installed, repoData) } @@ -338,18 +419,6 @@ class PyPackagingToolWindowPanel(service: PyPackagingToolWindowService, toolWind tablesView.resetSearch(installed, repos) } - fun displaySelectedPackageInfo(packageInfo: PackageInfo) { - currentPackageInfo = packageInfo - if (splitter?.secondComponent != rightPanel) { - splitter!!.secondComponent = rightPanel - } - - descriptionPanel.setHtml(packageInfo.description) - documentationUrl = packageInfo.documentationUrl - documentationLink.isVisible = documentationUrl != null - } - - fun startProgress() { progressBar.isVisible = true hideInstallableControls() @@ -385,20 +454,13 @@ class PyPackagingToolWindowPanel(service: PyPackagingToolWindowService, toolWind versionSelector.isVisible = false } - fun packageInstalled(newFiltered: List) { - tablesView.packagesAdded(newFiltered) - } - - fun packageDeleted(deletedPackage: DisplayablePackage) { - tablesView.packageDeleted(deletedPackage) - } - - fun showHeaderForPackage(selectedPackage: DisplayablePackage) { + private fun showHeaderForPackage(selectedPackage: DisplayablePackage) { packageNameLabel.text = selectedPackage.name packageNameLabel.isVisible = true documentationLink.isVisible = false if (selectedPackage is InstalledPackage) { + @Suppress("HardCodedStringLiteral") versionLabel.text = selectedPackage.instance.version showInstalledControls() } @@ -415,16 +477,14 @@ class PyPackagingToolWindowPanel(service: PyPackagingToolWindowService, toolWind splitter?.secondComponent = noPackagePanel } + override fun dispose() { + packagingScope.cancel() + } companion object { private const val HORIZONTAL_SPLITTER_KEY = "Python.PackagingToolWindow.Horizontal" private const val VERTICAL_SPLITTER_KEY = "Python.PackagingToolWindow.Vertical" - - val REMOTE_INTERPRETER_TEXT: String - get() = message("python.toolwindow.packages.remote.interpreter.placeholder") - val REQUEST_FAILED_TEXT: String - get() = message("python.toolwindow.packages.request.failed") val NO_DESCRIPTION: String get() = message("python.toolwindow.packages.no.description.placeholder") } diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt index 06cb9703926f..cf9d63e01e14 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/PyPackagingToolWindowService.kt @@ -1,19 +1,15 @@ // 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.packaging.toolwindow -import com.google.gson.Gson import com.intellij.ProjectTopics -import com.intellij.execution.ExecutionException +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType import com.intellij.openapi.Disposable -import com.intellij.openapi.application.AppUIExecutor -import com.intellij.openapi.application.impl.coroutineDispatchingContext +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.options.ex.SingleConfigurableEditor -import com.intellij.openapi.progress.ProgressIndicator -import com.intellij.openapi.progress.ProgressManager -import com.intellij.openapi.progress.Task import com.intellij.openapi.progress.withBackgroundProgressIndicator import com.intellij.openapi.project.Project import com.intellij.openapi.projectRoots.Sdk @@ -21,282 +17,179 @@ import com.intellij.openapi.roots.ModuleRootEvent import com.intellij.openapi.roots.ModuleRootListener import com.intellij.openapi.util.text.StringUtil import com.intellij.openapi.vfs.VirtualFile -import com.intellij.util.io.HttpRequests -import com.intellij.webcore.packaging.PackageManagementService -import com.intellij.webcore.packaging.RepoPackage +import com.intellij.util.childScope import com.jetbrains.python.PyBundle.* import com.jetbrains.python.PythonHelper import com.jetbrains.python.packaging.* -import com.jetbrains.python.packaging.PyPackageVersionComparator.STR_COMPARATOR +import com.jetbrains.python.packaging.common.PythonPackageDetails +import com.jetbrains.python.packaging.common.PythonPackageSpecification +import com.jetbrains.python.packaging.common.PythonPackageManagementListener +import com.jetbrains.python.packaging.management.PythonPackageManager +import com.jetbrains.python.packaging.management.packagesByRepository import com.jetbrains.python.packaging.repository.* -import com.jetbrains.python.packaging.ui.PyPackageManagementService import com.jetbrains.python.sdk.PySdkUtil -import com.jetbrains.python.sdk.PythonSdkUtil import com.jetbrains.python.sdk.pythonSdk import com.jetbrains.python.statistics.modules import kotlinx.coroutines.* import org.intellij.plugins.markdown.ui.preview.html.MarkdownUtil +import org.jetbrains.annotations.Nls +import kotlin.Comparator @Service class PyPackagingToolWindowService(val project: Project) : Disposable { private lateinit var toolWindowPanel: PyPackagingToolWindowPanel + lateinit var manager: PythonPackageManager private var installedPackages: List = emptyList() internal var currentSdk: Sdk? = null - private var selectedPackage: DisplayablePackage? = null - private var selectedInfo: PackageInfo? = null - private var currentJob: Job? = null private var searchJob: Job? = null private var currentQuery: String = "" - private val gson = Gson() + private val serviceScope = ApplicationManager.getApplication().coroutineScope.childScope(Dispatchers.Default) + private val invalidRepositories: List + get() = service().invalidRepositories.map(::PyInvalidRepositoryViewData) fun initialize(toolWindowPanel: PyPackagingToolWindowPanel) { this.toolWindowPanel = toolWindowPanel - initForSdk(project.modules.firstOrNull()?.pythonSdk) - GlobalScope.launch(Dispatchers.IO) { - val managementService = PyPackageManagers.getInstance().getManagementService(project, currentSdk) as PyPackageManagementService - managementService.allPackages // load packages - handleSearch("") + serviceScope.launch(Dispatchers.IO) { + PyPIPackageRanking.reload() + initForSdk(project.modules.firstOrNull()?.pythonSdk) } subscribeToChanges() } - fun packageSelected(pkg: DisplayablePackage) { - currentJob?.cancel() - selectedPackage = pkg - toolWindowPanel.showHeaderForPackage(pkg) - currentJob = GlobalScope.launch(Dispatchers.Default) { - if (PythonSdkUtil.isRemote(currentSdk)) { - selectedInfo = REMOTE_INTERPRETER_INFO - } - else { - val response = fetchPackageInfo(selectedPackage!!) - if (response != null) { - val packageDetails = gson.fromJson(response, PyPIPackageUtil.PackageDetails::class.java) - - selectedInfo = with(packageDetails.info) { - val renderedDescription = when { - description.isNotEmpty() -> convertToHTML(descriptionContentType, description) - summary.isNotEmpty() -> wrapHtml(summary) - else -> PyPackagingToolWindowPanel.NO_DESCRIPTION - } - PackageInfo(projectUrls["Documentation"], - renderedDescription, - packageDetails.releases.sortedWith(STR_COMPARATOR.reversed())) - } - } - else { - // try to get version from repository - val versionFromRepo = fetchVersionsFromPage(selectedPackage!!) - if (versionFromRepo.isNotEmpty()) { - selectedInfo = PackageInfo(null, - wrapHtml("

${message("python.toolwindow.packages.no.documentation")}

"), - versionFromRepo) - } - else { - selectedInfo = EMPTY_INFO - } - } - } - - if (isActive) { - withContext(AppUIExecutor.onUiThread().coroutineDispatchingContext()) { - toolWindowPanel.displaySelectedPackageInfo(selectedInfo!!) - } - } - } + suspend fun detailsForPackage(selectedPackage: DisplayablePackage): PythonPackageDetails { + val spec = selectedPackage.repository.createPackageSpecification(selectedPackage.name) + return manager.repositoryManager.getPackageDetails(spec) } + fun handleSearch(query: String) { currentQuery = query if (query.isNotEmpty()) { searchJob?.cancel() - searchJob = GlobalScope.launch(Dispatchers.Default) { + searchJob = serviceScope.launch { val installed = installedPackages.filter { StringUtil.containsIgnoreCase(it.name, query) } - val managementService = PyPackageManagers.getInstance().getManagementService(project, currentSdk) as PyPackageManagementService - val invalidRepositories = service().invalidRepositories.map(::PyInvalidRepositoryViewData) - val packagesFromRepos = managementService.allPackagesByRepository.map { - filterPackagesForRepo(it.value, query, it.key) - } + val packagesFromRepos = manager.repositoryManager.packagesByRepository().map { + filterPackagesForRepo(it.second, query, it.first) + }.toList() if (isActive) { - withContext(AppUIExecutor.onUiThread().coroutineDispatchingContext()) { + withContext(Dispatchers.Main) { toolWindowPanel.showSearchResult(installed, packagesFromRepos + invalidRepositories) } } } } else { - val managementService = PyPackageManagers.getInstance().getManagementService(project, currentSdk) as PyPackageManagementService - val packagesFromRepos = managementService.allPackagesByRepository.map { entry -> - val repository = service() - .repositories - .find { repo -> repo.repositoryUrl == entry.key } ?: PyPIPackageRepository + val packagesByRepository = manager.repositoryManager.packagesByRepository().map { (repository, packages) -> + val shownPackages = packages.asSequence().limitDisplayableResult(repository) + PyPackagesViewData(repository, shownPackages, moreItems = packages.size - PACKAGES_LIMIT) + }.toList() - val (packagesSeq, size) = when { - PyPIPackageUtil.isPyPIRepository(entry.key) -> Pair(PyPIPackageRanking.names, PyPIPackageCache.getInstance().packageNames.size) - else -> Pair(entry.value.asSequence().map { it.name }, entry.value.size) - } - - val shownPackages = packagesSeq.limitDisplayableResult(repository) - PyPackagesViewData(repository, shownPackages, moreItems = size - PACKAGES_LIMIT) - } - val invalidRepositories = service().invalidRepositories.map(::PyInvalidRepositoryViewData) - - toolWindowPanel.resetSearch(installedPackages, packagesFromRepos + invalidRepositories) + toolWindowPanel.resetSearch(installedPackages, packagesByRepository + invalidRepositories) } } - fun installSelectedPackage(version: String?) { - val toInstall = selectedPackage as? InstallablePackage ?: return - val managementService = PyPackageManagers.getInstance().getManagementService(project, currentSdk) - val listener = object : PackageManagementService.Listener { - override fun operationStarted(packageName: String?) { - toolWindowPanel.startProgress() - } + suspend fun installPackage(specification: PythonPackageSpecification) { + manager.installPackage(specification) - override fun operationFinished(packageName: String?, errorDescription: PackageManagementService.ErrorDescription?) { - toolWindowPanel.stopProgress() - collectInstalledPackages { newPackages -> - val withRepo = newPackages.map { InstalledPackage(it.instance, toInstall.repository) } - installedPackages = installedPackages.filterNot { it in newPackages } + withRepo - - if (currentQuery.isNotEmpty()) { - val newFiltered = withRepo.filter { StringUtil.containsIgnoreCase(it.name, currentQuery) } - toolWindowPanel.packageInstalled(newFiltered) - } - else { - toolWindowPanel.packageInstalled(withRepo) - } - - } - } - } - - managementService.installPackage(RepoPackage(toInstall.name, toInstall.repository.urlForInstallation), version, false, null, listener, false) + showPackagingNotification(message("python.packaging.notification.installed", specification.name)) } - fun deleteSelectedPackage() { - val packageToDelete = selectedPackage as? InstalledPackage ?: return - val managementService = PyPackageManagers.getInstance().getManagementService(project, currentSdk) - val listener = object : PackageManagementService.Listener { - override fun operationStarted(packageName: String?) { - toolWindowPanel.startProgress() - } - - override fun operationFinished(packageName: String?, errorDescription: PackageManagementService.ErrorDescription?) { - toolWindowPanel.stopProgress() - collectInstalledPackages { - toolWindowPanel.packageDeleted(packageToDelete) - if (packageToDelete.name == selectedPackage?.name) { - selectedPackage = null - toolWindowPanel.setEmpty() - } - } - } - } - managementService.uninstallPackages(listOf(packageToDelete.instance), listener) + suspend fun deletePackage(selectedPackage: InstalledPackage) { + manager.uninstallPackage(selectedPackage.instance) + showPackagingNotification(message("python.packaging.notification.deleted", selectedPackage.name)) } - private fun initForSdk(sdk: Sdk?) { + private suspend fun initForSdk(sdk: Sdk?) { val previousSdk = currentSdk currentSdk = sdk if (currentSdk != null) { - collectInstalledPackages(resetSearch = true) + manager = PythonPackageManager.forSdk(project, currentSdk!!) ?: error("No packages manager found for sdk: ${sdk?.name}") + manager.repositoryManager.initCaches() + manager.reloadPackages() + refreshInstalledPackages() + withContext(Dispatchers.Main) { + handleSearch("") + } } - toolWindowPanel.contentVisible = currentSdk != null - if (currentSdk == null || currentSdk != previousSdk) { - selectedPackage = null - toolWindowPanel.setEmpty() + withContext(Dispatchers.Main) { + toolWindowPanel.contentVisible = currentSdk != null + if (currentSdk == null || currentSdk != previousSdk) { + toolWindowPanel.setEmpty() + } } } private fun subscribeToChanges() { val connection = project.messageBus.connect(this) - connection.subscribe(PyPackageManager.PACKAGE_MANAGER_TOPIC, PyPackageManager.Listener { - if (currentSdk == it) collectInstalledPackages(resetSearch = currentQuery.isEmpty()) + connection.subscribe(PythonPackageManager.PACKAGE_MANAGEMENT_TOPIC, object : PythonPackageManagementListener { + override fun packagesChanged(sdk: Sdk) { + if (currentSdk == sdk) serviceScope.launch(Dispatchers.IO) { + refreshInstalledPackages() + withContext(Dispatchers.Main) { + handleSearch(currentQuery) + } + } + } }) connection.subscribe(ProjectTopics.PROJECT_ROOTS, object : ModuleRootListener { override fun rootsChanged(event: ModuleRootEvent) { - initForSdk(project.modules.firstOrNull()?.pythonSdk) + serviceScope.launch(Dispatchers.IO) { + initForSdk(project.modules.firstOrNull()?.pythonSdk) + } } }) } - private fun collectInstalledPackages(resetSearch: Boolean = false, callback: ((List) -> Unit)? = null) { - val task = object : Task.Backgroundable(project, message("python.toolwindow.packages.collecting.packages.task.title")) { - val sdk = currentSdk!! - val previouslyInstalled = installedPackages - var packages: List? = null - var newPackages: List? = null - override fun run(indicator: ProgressIndicator) { - val currentlyInstalled = previouslyInstalled.mapTo(HashSet()) { it.name } - packages = PyPackageManagers.getInstance().forSdk(sdk).refreshAndGetPackages(false).map { - val repository = previouslyInstalled.find { pkg -> pkg.name == it.name }?.repository ?: PyEmptyPackagePackageRepository - InstalledPackage(it, repository) - } - newPackages = packages?.filter { it.name !in currentlyInstalled } - } - override fun onSuccess() { - installedPackages = packages ?: error("No installed packages found") - if (resetSearch) handleSearch("") - callback?.invoke(newPackages ?: emptyList()) - } + suspend fun refreshInstalledPackages() { + val packages = manager.installedPackages.map { + val repository = installedPackages.find { pkg -> pkg.name == it.name }?.repository ?: PyEmptyPackagePackageRepository + InstalledPackage(it, repository) + } + + withContext(Dispatchers.Main) { + installedPackages = packages } - ProgressManager.getInstance().run(task) } - private suspend fun fetchPackageInfo(pkg: DisplayablePackage): String? = withContext(Dispatchers.IO) { - val result = runCatching { - val repoUrl = pkg.repository.repositoryUrl.let { if (it.isNullOrEmpty()) PyPIPackageUtil.PYPI_LIST_URL else it } - val packageUrl = repoUrl.replace("simple", "pypi/${pkg.name}/json") - HttpRequests.request(packageUrl) - .withBasicAuthorization(pkg.repository) - .readTimeout(3000) - .readString() + private suspend fun showPackagingNotification(text: @Nls String) { + val notification = NotificationGroupManager.getInstance() + .getNotificationGroup("PythonPackages") + .createNotification(text, NotificationType.INFORMATION) + + withContext(Dispatchers.Main) { + notification.notify(project) } - if (result.isFailure) thisLogger().debug("Request failed for package $pkg.name") - result.getOrNull() } - private fun filterPackagesForRepo(packageNames: List, + private fun filterPackagesForRepo(packageNames: List, query: String, - url: String, + repository: PyPackageRepository, skipItems: Int = 0): PyPackagesViewData { - val comparator = createComparator(query, url) - - val repository = service().findByUrl(url) ?: PyPIPackageRepository + val comparator = createNameComparator(query, repository.repositoryUrl ?: "") val searchResult = packageNames.asSequence() - .filter { StringUtil.containsIgnoreCase(it.name, query) } + .filter { StringUtil.containsIgnoreCase(it, query) } .toList() val shownPackages = searchResult.asSequence() .sortedWith(comparator) - .map { it.name } .limitDisplayableResult(repository, skipItems) - val exactMatch = shownPackages.indexOfFirst { StringUtil.equalsIgnoreCase(it.name, query) } return PyPackagesViewData(repository, shownPackages, exactMatch, searchResult.size - shownPackages.size) } - - private suspend fun fetchVersionsFromPage(pkg: DisplayablePackage): List = withContext(Dispatchers.IO) { - val result = runCatching { - val url = StringUtil.trimEnd(pkg.repository.repositoryUrl!!, "/") + "/" + pkg.name - PyPIPackageUtil.parsePackageVersionsFromArchives(url, pkg.name) - } - return@withContext result.getOrDefault(emptyList()).sortedWith(STR_COMPARATOR.reversed()) - } - - private fun convertToHTML(contentType: String, description: String): String { - return when (contentType) { - "text/markdown" -> markdownToHtml(description, currentSdk!!.homeDirectory!!, project) - "text/x-rst", "" -> rstToHtml(description, currentSdk!!) - else -> description + suspend fun convertToHTML(contentType: String?, description: String): String { + return withContext(Dispatchers.IO) { + when (contentType) { + "text/markdown" -> markdownToHtml(description, currentSdk!!.homeDirectory!!, project) + "text/x-rst", "" -> rstToHtml(description, currentSdk!!) + else -> description + } } } @@ -315,18 +208,20 @@ class PyPackagingToolWindowService(val project: Project) : Disposable { } override fun dispose() { - currentJob?.cancel() searchJob?.cancel() + serviceScope.cancel() } - private fun wrapHtml(html: String): String = "

$html

" + fun wrapHtml(html: String): String = "

$html

" fun reloadPackages() { - GlobalScope.launch(Dispatchers.IO) { + serviceScope.launch(Dispatchers.IO) { withBackgroundProgressIndicator(project, message("python.packaging.loading.packages.progress.text"), cancellable = false) { - val managementService = PyPackageManagers.getInstance().getManagementService(project, currentSdk) - PyPIPackageUtil.INSTANCE.loadAdditionalPackages(managementService.allRepositories!!, true) - withContext(AppUIExecutor.onUiThread().coroutineDispatchingContext()) { + manager.reloadPackages() + manager.repositoryManager.refreshCashes() + refreshInstalledPackages() + + withContext(Dispatchers.Main) { handleSearch("") } } @@ -336,7 +231,7 @@ class PyPackagingToolWindowService(val project: Project) : Disposable { fun manageRepositories() { val updated = SingleConfigurableEditor(project, PyRepositoriesList(project)).showAndGet() if (updated) { - GlobalScope.launch(Dispatchers.IO) { + serviceScope.launch(Dispatchers.IO) { val packageService = PyPackageService.getInstance() val repositoryService = service() val allRepos = repositoryService.repositories.map { it.repositoryUrl } @@ -360,42 +255,14 @@ class PyPackagingToolWindowService(val project: Project) : Disposable { } fun getMoreResultsForRepo(repository: PyPackageRepository, skipItems: Int): PyPackagesViewData { - val managementService = PyPackageManagers.getInstance().getManagementService(project, currentSdk) as PyPackageManagementService - val fromCurrentRepo = managementService.allPackagesByRepository[repository.repositoryUrl]!! + val packagesFromRepository = manager.repositoryManager.packagesFromRepository(repository) + if (currentQuery.isNotEmpty()) { - return filterPackagesForRepo(fromCurrentRepo, currentQuery, repository.repositoryUrl!!, skipItems) + return filterPackagesForRepo(packagesFromRepository, currentQuery, repository, skipItems) } else { - // The number of items to skip might be more than the number of ranked packages we store, - // so we need to include the remaining packages from pypi, filtering out those, that are already shown. - if (PyPIPackageUtil.isPyPIRepository(repository.repositoryUrl)) { - val ranked = PyPIPackageRanking.packageRank - val names = PyPIPackageRanking.names - val rankedSize = PyPIPackageRanking.packageRank.size - val pypiAdjusted = when { - skipItems > rankedSize -> fromCurrentRepo.asSequence().drop(skipItems - rankedSize).map { it.name } - skipItems + PACKAGES_LIMIT > rankedSize -> { - val pypiRemaining = fromCurrentRepo.asSequence() - .map { it.name } - .filterNot { it in ranked } - - names.drop(skipItems) + pypiRemaining - } - else -> names.drop(skipItems) - } - - - val pypiPackages = pypiAdjusted.limitDisplayableResult(repository) - - val packageNum = PyPIPackageCache.getInstance().packageNames.size - return PyPackagesViewData(repository, pypiPackages, moreItems = packageNum - PACKAGES_LIMIT) - } - - val packagesFromRepo = fromCurrentRepo.asSequence() - .map { it.name } - .limitDisplayableResult(repository, skipItems) - - return PyPackagesViewData(repository, packagesFromRepo, moreItems = fromCurrentRepo.size - (PACKAGES_LIMIT + skipItems)) + val packagesFromRepo = packagesFromRepository.asSequence().limitDisplayableResult(repository, skipItems) + return PyPackagesViewData(repository, packagesFromRepo, moreItems = packagesFromRepository.size - (PACKAGES_LIMIT + skipItems)) } } @@ -406,30 +273,11 @@ class PyPackagingToolWindowService(val project: Project) : Disposable { .toList() } - fun installFromLocation(location: String, editable: Boolean) { - val ui = PyPackageManagerUI(project, currentSdk!!, object : PyPackageManagerUI.Listener { - override fun started() {} - - override fun finished(exceptions: MutableList?) { - handleSearch("") - } - }) - val installOptions = if (editable) listOf("-e", location) else listOf(location) - - ui.install(listOf(PyRequirementImpl(location, emptyList(), installOptions, "")), emptyList()) - } - - companion object { - private val EMPTY_INFO = PackageInfo(null, PyPackagingToolWindowPanel.REQUEST_FAILED_TEXT, emptyList()) - private val REMOTE_INTERPRETER_INFO = PackageInfo(null, PyPackagingToolWindowPanel.REMOTE_INTERPRETER_TEXT, emptyList()) private const val PACKAGES_LIMIT = 50 - - private fun createComparator(query: String, url: String): Comparator { - val nameComparator = Comparator { o1, o2 -> - val name1 = o1.name.toLowerCase() - val name2 = o2.name.toLowerCase() - val queryLowerCase = query.toLowerCase() + private fun createNameComparator(query: String, url: String): Comparator { + val nameComparator = Comparator { name1, name2 -> + val queryLowerCase = query.lowercase() return@Comparator when { name1.startsWith(queryLowerCase) && name2.startsWith(queryLowerCase) -> name1.length - name2.length name1.startsWith(queryLowerCase) -> -1 @@ -441,8 +289,8 @@ class PyPackagingToolWindowService(val project: Project) : Disposable { if (PyPIPackageUtil.isPyPIRepository(url)) { val ranking = PyPIPackageRanking.packageRank return Comparator { p1, p2 -> - val rank1 = ranking[p1.name.toLowerCase()] - val rank2 = ranking[p2.name.toLowerCase()] + val rank1 = ranking[p1.lowercase()] + val rank2 = ranking[p2.lowercase()] return@Comparator when { rank1 != null && rank2 == null -> -1 rank1 == null && rank2 != null -> 1 diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/actions.kt b/python/src/com/jetbrains/python/packaging/toolwindow/actions.kt new file mode 100644 index 000000000000..f9c124a1da20 --- /dev/null +++ b/python/src/com/jetbrains/python/packaging/toolwindow/actions.kt @@ -0,0 +1,31 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.packaging.toolwindow + +import com.intellij.openapi.components.service +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.project.Project +import com.jetbrains.python.packaging.common.PythonPackageDetails +import com.jetbrains.python.packaging.common.PythonPackageSpecification +import com.jetbrains.python.packaging.management.PythonPackageManager +import org.jetbrains.annotations.Nls + +interface PythonPackagingToolwindowActionProvider { + fun getInstallActions(details: PythonPackageDetails, packageManager: PythonPackageManager): List? + + companion object { + val EP_NAME = ExtensionPointName.create("Pythonid.PythonPackagingToolwindowActionProvider") + } +} + +abstract class PythonPackageInstallAction(internal val text: @Nls String, + internal val project: Project) { + + abstract suspend fun installPackage(specification: PythonPackageSpecification) +} + +class SimplePythonPackageInstallAction(text: @Nls String, + project: Project) : PythonPackageInstallAction(text, project) { + override suspend fun installPackage(specification: PythonPackageSpecification) { + project.service().installPackage(specification) + } +} \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/modelComponents.kt b/python/src/com/jetbrains/python/packaging/toolwindow/modelComponents.kt index b6ee8ecc599d..f47a86871072 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/modelComponents.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/modelComponents.kt @@ -2,15 +2,12 @@ package com.jetbrains.python.packaging.toolwindow import com.intellij.openapi.util.NlsSafe -import com.jetbrains.python.packaging.PyPackage import com.jetbrains.python.packaging.repository.PyPackageRepository +import com.jetbrains.python.packaging.common.PythonPackage sealed class DisplayablePackage(@NlsSafe val name: String, val repository: PyPackageRepository) -class InstalledPackage(val instance: PyPackage, repository: PyPackageRepository) : DisplayablePackage(instance.name, repository) +class InstalledPackage(val instance: PythonPackage, repository: PyPackageRepository) : DisplayablePackage(instance.name, repository) class InstallablePackage(name: String, repository: PyPackageRepository) : DisplayablePackage(name, repository) class ExpandResultNode(var more: Int, repository: PyPackageRepository) : DisplayablePackage("", repository) - -class PackageInfo(val documentationUrl: String?, @NlsSafe val description: String, val availableVersions: List) - open class PyPackagesViewData(@NlsSafe val repository: PyPackageRepository, val packages: List, val exactMatch: Int = -1, val moreItems: Int = 0) class PyInvalidRepositoryViewData(repository: PyPackageRepository) : PyPackagesViewData(repository, emptyList()) \ No newline at end of file diff --git a/python/src/com/jetbrains/python/packaging/toolwindow/uiComponents.kt b/python/src/com/jetbrains/python/packaging/toolwindow/uiComponents.kt index 0f809c69a4a0..88d14ec58cc6 100644 --- a/python/src/com/jetbrains/python/packaging/toolwindow/uiComponents.kt +++ b/python/src/com/jetbrains/python/packaging/toolwindow/uiComponents.kt @@ -2,6 +2,8 @@ package com.jetbrains.python.packaging.toolwindow import com.intellij.icons.AllIcons +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project import com.intellij.openapi.util.NlsSafe import com.intellij.ui.DoubleClickListener import com.intellij.ui.SideBorder @@ -24,9 +26,13 @@ import javax.swing.table.DefaultTableCellRenderer import javax.swing.table.TableCellRenderer -internal class PyPackagesTable(model: ListTableModel, service: PyPackagingToolWindowService, tablesView: PyPackagingTablesView) : JBTable(model) { +internal class PyPackagesTable(project: Project, + model: ListTableModel, + tablesView: PyPackagingTablesView, + controller: PyPackagingToolWindowPanel) : JBTable(model) { private var lastSelectedRow = -1 init { + val service = project.service() setShowGrid(false) setSelectionMode(ListSelectionModel.SINGLE_SELECTION) val column = columnModel.getColumn(1) @@ -42,7 +48,7 @@ internal class PyPackagesTable(model: ListTableModel, lastSelectedRow = selectedRow tablesView.requestSelection(this) val pkg = model.items[selectedRow] - if (pkg !is ExpandResultNode) service.packageSelected(pkg) + if (pkg !is ExpandResultNode) controller.packageSelected(pkg) } } @@ -179,7 +185,7 @@ fun borderPanel(init: JPanel.() -> Unit) = object : JPanel() { fun headerPanel(label: JLabel, component: JComponent?) = object : JPanel() { init { - background = UIUtil.getControlColor() + background = UIUtil.getLabelBackground() layout = BorderLayout() border = BorderFactory.createCompoundBorder(SideBorder(NamedColorUtil.getBoundsColor(), SideBorder.BOTTOM), EmptyBorder(0, 5, 0, 5)) preferredSize = Dimension(preferredSize.width, 25) diff --git a/python/src/com/jetbrains/python/sdk/PythonSdkUpdater.java b/python/src/com/jetbrains/python/sdk/PythonSdkUpdater.java index 903c48e9c581..68b2f99d8b6a 100644 --- a/python/src/com/jetbrains/python/sdk/PythonSdkUpdater.java +++ b/python/src/com/jetbrains/python/sdk/PythonSdkUpdater.java @@ -43,6 +43,8 @@ import com.jetbrains.python.PyBundle; import com.jetbrains.python.codeInsight.typing.PyTypeShed; import com.jetbrains.python.codeInsight.userSkeletons.PyUserSkeletonsUtil; import com.jetbrains.python.packaging.PyPackageManager; +import com.jetbrains.python.packaging.management.PythonPackageManagerExt; +import com.jetbrains.python.packaging.management.PythonPackageManager; import com.jetbrains.python.psi.PyUtil; import com.jetbrains.python.remote.UnsupportedPythonSdkTypeException; import com.jetbrains.python.sdk.skeletons.PySkeletonRefresher; @@ -179,6 +181,10 @@ public class PythonSdkUpdater implements StartupActivity.Background { indicator.setText(PyBundle.message("python.sdk.scanning.installed.packages")); indicator.setText2(""); PyPackageManager.getInstance(sdk).refreshAndGetPackages(true); + PythonPackageManager manager = PythonPackageManager.Companion.forSdk(myProject, mySdk); + if (manager != null) { + PythonPackageManagerExt.launchReload(manager); + } } catch (ExecutionException e) { if (LOG.isDebugEnabled()) { diff --git a/python/src/com/jetbrains/python/sdk/flavors/conda/PyCondaEnv.kt b/python/src/com/jetbrains/python/sdk/flavors/conda/PyCondaEnv.kt index bc769d55ef03..abf63c4d903e 100644 --- a/python/src/com/jetbrains/python/sdk/flavors/conda/PyCondaEnv.kt +++ b/python/src/com/jetbrains/python/sdk/flavors/conda/PyCondaEnv.kt @@ -87,6 +87,24 @@ suspend fun createEnv(command: PyCondaCommand, newCondaEnvInfo: NewCondaEnvRequ } } + /** + * Add conda prefix to [targetedCommandLineBuilder] without specifying the 'run' command + */ + fun addCondaEnvironmentToTargetBuilder(targetedCommandLineBuilder: TargetedCommandLineBuilder) { + targetedCommandLineBuilder.apply { + when (val identity = this@PyCondaEnv.envIdentity) { + is PyCondaEnvIdentity.UnnamedEnv -> { + addParameter("-p") + addParameter(identity.envPath) // TODO: Escape. Shouldn't target have something like "addEscaped"? + } + is PyCondaEnvIdentity.NamedEnv -> { + addParameter("-n") + addParameter(identity.envName) + } + } + } + } + override fun toString(): String = "$envIdentity@$fullCondaPathOnTarget" }