[python] support python dependencies in pyproject.toml (PY-59844)

- completion for package names in `dependencies` and `install-requires`
- completion for build-backends
- if a package is not installed, provide a quickfix to install it / run `pip install -e .`

GitOrigin-RevId: cad88e4058a45f6db717b8da8fd7f6c456008998
This commit is contained in:
Aleksei Kniazev
2023-06-15 17:33:48 +02:00
committed by intellij-monorepo-bot
parent 03a517b29d
commit 2180aa6230
13 changed files with 292 additions and 19 deletions

View File

@@ -0,0 +1,8 @@
<html>
<body>
Reports unsatisfied dependencies, declared [project.dependencies] table in pyproject.toml.
<p>
Shows a quick-fix to install missing packages.
</p>
</body>
</html>

View File

@@ -1297,6 +1297,15 @@ conda.packaging.error.rendering.description=<html><head></head><body><p class="e
conda.packaging.exception.timeout=Time out
conda.packaging.exception.non.zero=Non-zero exit code
# pyproject.toml
INSP.NAME.pyproject.packages=Depencencies in pyproject.toml
python.pyproject.package.completion.tail=Python package
python.pyproject.package.not.installed=Package {0} is not installed
python.pyproject.install.package=Install package {0}
python.pyproject.install.self.as.editable=Run 'pip install -e .'
python.pyproject.install.self.as.editable.progress=Running 'pip install -e .'
python.pyproject.install.self.error=Error Running 'pip install -e .'
# Python Packages toolwindow
python.toolwindow.packages.installed.label=Installed
python.toolwindow.packages.custom.repo.searched={0} ({1} found)

View File

@@ -489,6 +489,19 @@
<notificationGroup id="pyproject.toml Watcher" displayType="STICKY_BALLOON" isLogByDefault="true" bundle="messages.PyBundle"
key="python.sdk.poetry.pip.file.watcher"/>
<completion.contributor language="TOML"
implementationClass="com.jetbrains.python.packaging.pyproject.PyprojectPackageCompletionContributor"/>
<completion.contributor language="TOML"
implementationClass="com.jetbrains.python.packaging.pyproject.PyprojectMetadataCompletionContributor"/>
<localInspection language="TOML"
shortName="PyprojectInspection"
suppressId="PyprojectInspection"
bundle="messages.PyBundle"
groupKey="INSP.GROUP.python"
key="INSP.NAME.pyproject.packages"
level="WARNING"
enabledByDefault="true"
implementationClass="com.jetbrains.python.packaging.pyproject.PyprojectPackageInspection"/>
<toolWindow id="Python Packages" anchor="bottom"
icon="PythonIcons.Python.PythonPackages"
factoryClass="com.jetbrains.python.packaging.toolwindow.PyPackagesToolWindowFactory"/>

View File

@@ -5,6 +5,7 @@ import com.intellij.openapi.diagnostic.thisLogger
import com.jetbrains.python.packaging.repository.PyEmptyPackagePackageRepository
import com.jetbrains.python.packaging.repository.PyPIPackageRepository
import com.jetbrains.python.packaging.repository.PyPackageRepository
import com.jetbrains.python.packaging.requirement.PyRequirementRelation
import org.jetbrains.annotations.Nls
open class PythonPackage(val name: String, val version: String) {
@@ -56,9 +57,10 @@ interface PythonPackageSpecification {
val name: String
val version: String?
val repository: PyPackageRepository?
val relation: PyRequirementRelation?
fun buildInstallationString(): List<String> = buildList {
val versionString = if (version != null) "==$version" else ""
val versionString = if (version != null) "${relation?.presentableText ?: "=="}$version" else ""
add("$name$versionString")
if (repository == PyEmptyPackagePackageRepository) {
thisLogger().warn("PyEmptyPackagePackageRepository used as source repository for package installation!")
@@ -79,12 +81,15 @@ interface PythonLocationBasedPackageSpecification : PythonPackageSpecification {
get() = null
override val repository: PyPackageRepository?
get() = null
override val relation: PyRequirementRelation?
get() = null
override fun buildInstallationString(): List<String> = 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
override val repository: PyPackageRepository?,
override val relation: PyRequirementRelation? = null) : PythonPackageSpecification
data class PythonLocalPackageSpecification(override val name: String,
override val location: String,

View File

@@ -5,6 +5,7 @@ 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
import com.jetbrains.python.packaging.requirement.PyRequirementRelation
class CondaPackage(name: String, version: String, val installedWithPip: Boolean = false) : PythonPackage(name, version) {
override fun toString(): String {
@@ -13,7 +14,8 @@ class CondaPackage(name: String, version: String, val installedWithPip: Boolean
}
class CondaPackageSpecification(override val name: String,
override val version: String?) : PythonPackageSpecification {
override val version: String?,
override val relation: PyRequirementRelation? = null) : PythonPackageSpecification {
override val repository: PyPackageRepository = CondaPackageRepository
override fun buildInstallationString(): List<String> {
@@ -34,7 +36,7 @@ class CondaPackageDetails(override val name: String,
}
object CondaPackageRepository : PyPackageRepository("Conda", "", "") {
override fun createPackageSpecification(packageName: String, version: String?): PythonPackageSpecification {
return CondaPackageSpecification(packageName, version)
override fun createPackageSpecification(packageName: String, version: String?, relation: PyRequirementRelation?): PythonPackageSpecification {
return CondaPackageSpecification(packageName, version, relation)
}
}

View File

@@ -5,17 +5,18 @@ 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.execution.target.value.constant
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.openapi.progress.*
import com.intellij.openapi.project.guessProjectDir
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.common.PythonPackageSpecification
import com.jetbrains.python.packaging.repository.PyPackageRepository
import com.jetbrains.python.packaging.requirement.PyRequirementRelation
import com.jetbrains.python.run.PythonInterpreterTargetEnvironmentFactory
import com.jetbrains.python.run.buildTargetedCommandLine
import com.jetbrains.python.run.prepareHelperScriptExecution
@@ -36,6 +37,9 @@ suspend fun PythonPackageManager.runPackagingTool(operation: String, arguments:
val pythonExecution = prepareHelperScriptExecution(PythonHelper.PACKAGING_TOOL, helpersAwareTargetRequest)
// todo[akniazev]: check applyWorkingDir: PyTargetEnvironmentPackageManager.java:133
project.guessProjectDir()?.path?.let {
pythonExecution.workingDir = constant(it)
}
pythonExecution.addParameter(operation)
if (operation == "install") {
@@ -72,8 +76,8 @@ suspend fun PythonPackageManager.runPackagingTool(operation: String, arguments:
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)
val result = withBackgroundProgress(project, text, cancellable = true) {
handler.runProcess(10 * 60 * 1000)
}
if (result.isCancelled) throw RunCanceledByUserException()
@@ -82,7 +86,7 @@ suspend fun PythonPackageManager.runPackagingTool(operation: String, arguments:
val helperPath = commandLine.firstOrNull() ?: ""
val args: List<String> = commandLine.subList(min(1, commandLine.size), commandLine.size)
if (exitCode != 0) {
val message = if (StringUtil.isEmptyOrSpaces(result.stdout) && StringUtil.isEmptyOrSpaces(result.stderr)) PySdkBundle.message(
val message = if (result.stdout.isBlank() && result.stderr.isBlank()) PySdkBundle.message(
"python.conda.permission.denied")
else PySdkBundle.message("python.sdk.packaging.non.zero.exit.code", exitCode)
throw PyExecutionException(message, helperPath, args, result)
@@ -107,4 +111,13 @@ private val proxyString: String?
fun PythonRepositoryManager.packagesByRepository(): Sequence<Pair<PyPackageRepository, List<String>>> {
return repositories.asSequence().map { it to packagesFromRepository(it) }
}
fun PythonPackageManager.isInstalled(name: String): Boolean {
return installedPackages.any { it.name.lowercase() == name.lowercase() }
}
fun PythonRepositoryManager.createSpecification(name: String, version: String? = null, relation: PyRequirementRelation? = null): PythonPackageSpecification {
val repository = packagesByRepository().first { it.second.any { pkg -> pkg.lowercase() == name.lowercase() } }.first
return repository.createPackageSpecification(name, version, relation)
}

View File

@@ -0,0 +1,49 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.packaging.pyproject
import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer
import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo
import com.intellij.codeInspection.LocalQuickFix
import com.intellij.codeInspection.ProblemDescriptor
import com.intellij.openapi.components.service
import com.intellij.openapi.module.ModuleUtilCore
import com.intellij.openapi.project.Project
import com.jetbrains.python.PyBundle
import com.jetbrains.python.packaging.PyRequirementParser
import com.jetbrains.python.packaging.management.PythonPackageManager
import com.jetbrains.python.packaging.management.createSpecification
import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowService
import com.jetbrains.python.sdk.pythonSdk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class PyInstallPackageQuickFix(val packageName: String) : LocalQuickFix {
override fun getFamilyName(): String {
return PyBundle.message("python.pyproject.install.package", packageName)
}
override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
val element = descriptor.psiElement
val file = descriptor.psiElement.containingFile ?: return
val sdk = ModuleUtilCore.findModuleForPsiElement(element)?.pythonSdk ?: return
val manager = PythonPackageManager.forSdk(project, sdk)
val requirement = PyRequirementParser.fromLine(element.text.removeSurrounding("\"")) ?: return
project.service<PyPackagingToolWindowService>().serviceScope.launch(Dispatchers.IO) {
val versionSpec = requirement.versionSpecs.firstOrNull()
val specification = manager.repositoryManager.createSpecification(requirement.name, versionSpec?.version, versionSpec?.relation)
manager.installPackage(specification)
DaemonCodeAnalyzer.getInstance(project).restart(file)
}
}
override fun generatePreview(project: Project, previewDescriptor: ProblemDescriptor): IntentionPreviewInfo {
return IntentionPreviewInfo.EMPTY
}
override fun startInWriteAction(): Boolean {
return false
}
}

View File

@@ -0,0 +1,53 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.packaging.pyproject
import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer
import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo
import com.intellij.codeInspection.LocalQuickFix
import com.intellij.codeInspection.ProblemDescriptor
import com.intellij.openapi.components.service
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.module.ModuleUtilCore
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.findDocument
import com.jetbrains.python.PyBundle
import com.jetbrains.python.packaging.common.runPackagingOperationOrShowErrorDialog
import com.jetbrains.python.packaging.management.PythonPackageManager
import com.jetbrains.python.packaging.management.runPackagingTool
import com.jetbrains.python.packaging.toolwindow.PyPackagingToolWindowService
import com.jetbrains.python.sdk.pythonSdk
import kotlinx.coroutines.launch
class PyInstallProjectAsEditableQuickfix : LocalQuickFix {
override fun getFamilyName(): String {
return PyBundle.message("python.pyproject.install.self.as.editable")
}
@Suppress("DialogTitleCapitalization")
override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
val element = descriptor.psiElement
val file = descriptor.psiElement.containingFile ?: return
val sdk = ModuleUtilCore.findModuleForPsiElement(element)?.pythonSdk ?: return
val manager = PythonPackageManager.forSdk(project, sdk)
FileDocumentManager.getInstance().saveDocument(file.virtualFile.findDocument() ?: return)
project.service<PyPackagingToolWindowService>().serviceScope.launch {
runPackagingOperationOrShowErrorDialog(sdk, PyBundle.message("python.pyproject.install.self.error"), null) {
manager.runPackagingTool("install", listOf("-e", "."), PyBundle.message("python.pyproject.install.self.as.editable.progress"))
manager.refreshPaths()
manager.reloadPackages()
}
DaemonCodeAnalyzer.getInstance(project).restart(file)
}
}
override fun startInWriteAction(): Boolean {
return false
}
override fun generatePreview(project: Project, previewDescriptor: ProblemDescriptor): IntentionPreviewInfo {
return IntentionPreviewInfo.EMPTY
}
}

View File

@@ -0,0 +1,39 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.packaging.pyproject
import com.intellij.codeInsight.completion.CompletionContributor
import com.intellij.codeInsight.completion.CompletionParameters
import com.intellij.codeInsight.completion.CompletionResultSet
import com.intellij.codeInsight.lookup.LookupElementBuilder
import com.intellij.psi.util.elementType
import com.intellij.psi.util.parentOfType
import icons.PythonIcons
import org.toml.lang.psi.TOML_STRING_LITERALS
import org.toml.lang.psi.TomlKeyValue
import org.toml.lang.psi.ext.name
class PyprojectMetadataCompletionContributor : CompletionContributor() {
private val knownBackends = listOf("setuptools.build_meta",
"setuptools.build_meta:__legacy__",
"poetry.core.masonry.api",
"flit_core.buildapi",
"pdm.backend",
"hatchling.build")
override fun fillCompletionVariants(parameters: CompletionParameters, result: CompletionResultSet) {
if (parameters.originalFile.name != "pyproject.toml") return
val position = parameters.position
val parent = position.parent
if (TOML_STRING_LITERALS.contains(parent.elementType) && parent.parentOfType<TomlKeyValue>()?.key?.name == "build-backend") {
knownBackends.map {
LookupElementBuilder.create(it).withIcon(PythonIcons.Python.Python)
}
.forEach { result.addElement(it) }
}
}
}

View File

@@ -0,0 +1,42 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.packaging.pyproject
import com.intellij.codeInsight.completion.CompletionContributor
import com.intellij.codeInsight.completion.CompletionParameters
import com.intellij.codeInsight.completion.CompletionResultSet
import com.intellij.codeInsight.completion.PrioritizedLookupElement
import com.intellij.codeInsight.lookup.LookupElementBuilder
import com.intellij.openapi.components.service
import com.intellij.psi.util.elementType
import com.intellij.psi.util.parentOfType
import com.jetbrains.python.PyBundle
import com.jetbrains.python.packaging.pip.PypiPackageCache
import icons.PythonIcons
import org.toml.lang.psi.TOML_STRING_LITERALS
import org.toml.lang.psi.TomlArray
import org.toml.lang.psi.TomlKeyValue
import org.toml.lang.psi.ext.name
class PyprojectPackageCompletionContributor : CompletionContributor() {
private val completionLocations = listOf("dependencies", "requires")
override fun fillCompletionVariants(parameters: CompletionParameters, result: CompletionResultSet) {
if (parameters.originalFile.name != "pyproject.toml") return
val position = parameters.position
val parent = position.parent
val key = parent.parentOfType<TomlKeyValue>()?.key?.name
if (TOML_STRING_LITERALS.contains(position.elementType)
&& parent.parent is TomlArray
&& key in completionLocations) {
val cache = service<PypiPackageCache>()
val maxPriority = cache.packages.size
cache.packages.asSequence()
.map { LookupElementBuilder.create(it.lowercase()).withTypeText(PyBundle.message("python.pyproject.package.completion.tail")).withIcon(PythonIcons.Python.Python) }
.mapIndexed { index, lookupElementBuilder ->
PrioritizedLookupElement.withPriority(lookupElementBuilder, (maxPriority - index).toDouble())
}
.forEach { result.addElement(it) }
}
}
}

View File

@@ -0,0 +1,41 @@
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.packaging.pyproject
import com.intellij.codeInspection.LocalInspectionTool
import com.intellij.codeInspection.ProblemsHolder
import com.intellij.openapi.module.ModuleUtilCore
import com.intellij.psi.PsiElementVisitor
import com.intellij.psi.util.elementType
import com.intellij.psi.util.parentOfType
import com.jetbrains.python.PyBundle
import com.jetbrains.python.packaging.PyRequirementParser
import com.jetbrains.python.packaging.management.PythonPackageManager
import com.jetbrains.python.packaging.management.isInstalled
import com.jetbrains.python.sdk.pythonSdk
import org.toml.lang.psi.*
import org.toml.lang.psi.ext.name
class PyprojectPackageInspection : LocalInspectionTool() {
override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor {
return object : TomlVisitor() {
override fun visitLiteral(element: TomlLiteral) {
if (element.parent is TomlArray && TOML_STRING_LITERALS.contains(element.firstChild.elementType)) {
if (element.parentOfType<TomlKeyValue>()?.key?.name == "dependencies") {
val elementText = element.text.removeSurrounding("'").removeSurrounding("\"")
val requirement = PyRequirementParser.fromLine(elementText) ?: return
val module = ModuleUtilCore.findModuleForPsiElement(element.originalElement) ?: return
val packageManager = PythonPackageManager.forSdk(element.project, module.pythonSdk ?: return)
if (!packageManager.isInstalled(requirement.name)) {
holder.registerProblem(element, PyBundle.message("python.pyproject.package.not.installed", requirement.name), PyInstallPackageQuickFix(requirement.name), PyInstallProjectAsEditableQuickfix())
}
}
}
}
}
}
}

View File

@@ -9,6 +9,7 @@ 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 com.jetbrains.python.packaging.requirement.PyRequirementRelation
import org.jetbrains.annotations.ApiStatus
@ApiStatus.Experimental
@@ -59,7 +60,8 @@ open class PyPackageRepository() : BaseState() {
}
open fun createPackageSpecification(packageName: String,
version: String? = null): PythonPackageSpecification {
return PythonSimplePackageSpecification(packageName, version, this)
version: String? = null,
relation: PyRequirementRelation? = null): PythonPackageSpecification {
return PythonSimplePackageSpecification(packageName, version, this, relation)
}
}

View File

@@ -7,7 +7,6 @@ import com.intellij.execution.target.TargetProgressIndicator
import com.intellij.notification.NotificationGroupManager
import com.intellij.notification.NotificationType
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.thisLogger
@@ -25,7 +24,6 @@ import com.intellij.openapi.roots.ModuleRootListener
import com.intellij.openapi.roots.ProjectRootManager
import com.intellij.openapi.util.text.StringUtil
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.util.childScope
import com.jetbrains.python.PyBundle.*
import com.jetbrains.python.PythonHelper
import com.jetbrains.python.PythonHelpersLocator
@@ -50,8 +48,8 @@ import kotlinx.coroutines.*
import org.intellij.plugins.markdown.ui.preview.html.MarkdownUtil
import org.jetbrains.annotations.Nls
@Service
class PyPackagingToolWindowService(val project: Project) : Disposable {
@Service(Service.Level.PROJECT)
class PyPackagingToolWindowService(val project: Project, val serviceScope: CoroutineScope) : Disposable {
private var toolWindowPanel: PyPackagingToolWindowPanel? = null
lateinit var manager: PythonPackageManager
@@ -59,7 +57,6 @@ class PyPackagingToolWindowService(val project: Project) : Disposable {
internal var currentSdk: Sdk? = null
private var searchJob: Job? = null
private var currentQuery: String = ""
private val serviceScope = ApplicationManager.getApplication().coroutineScope.childScope(Dispatchers.Default)
private val invalidRepositories: List<PyInvalidRepositoryViewData>
get() = service<PyPackageRepositories>().invalidRepositories.map(::PyInvalidRepositoryViewData)