[pycharm] PY-89747 Add DependenciesInspectionProvider impl for legacy Poetry

fixup! [pycharm] PY-89747 Add DependenciesInspectionProvider impl for legacy Poetry
[pycharm] PY-89747 Add DependenciesInspectionProvider impl for legacy Poetry


Co-authored-by: Morgan Bartholomew <morgan.bartholomew@jetbrains.com>

Merge-request: IJ-MR-211850
Merged-by: David Lysenko <david.lysenko@jetbrains.com>

(cherry picked from commit 147e19a3dea8c6a322f9a4a0dbf06b5972710819)

IJ-MR-211850

GitOrigin-RevId: 8f5aa63e3381746ee70b216a5198524ad4bebdc6
This commit is contained in:
David Lysenko
2026-07-08 12:57:22 +00:00
committed by intellij-monorepo-bot
co-authored by Morgan Bartholomew
parent d84d493dc5
commit 032935307a
14 changed files with 197 additions and 128 deletions
+2
View File
@@ -1407,6 +1407,8 @@ jvm_library(
"//libraries/junit4:junit4_test_lib",
"//plugins/toml/core",
"//plugins/toml/core:core_test_lib",
"//python/python-poetry/backend",
"//python/python-poetry/backend:backend_test_lib",
],
)
### auto-generated section `build intellij.python.community.tests` end
@@ -140,6 +140,7 @@
<orderEntry type="module" module-name="intellij.platform.testIntegration.ui" scope="TEST" />
<orderEntry type="module" module-name="intellij.libraries.junit4" scope="TEST" />
<orderEntry type="module" module-name="intellij.toml.core" scope="TEST" />
<orderEntry type="module" module-name="intellij.python.community.impl.poetry.backend" scope="TEST" />
</component>
<component name="TestModuleProperties" production-module="intellij.python.community.impl" />
</module>
@@ -55,10 +55,6 @@
implementation="com.jetbrains.python.requirements.widget.PyDependenciesFileStatusBarWidgetFactory"
order="after pythonInterpreterWidget, before git, before hg, before Notifications"/>
<localInspection language="TOML" enabledByDefault="true"
implementationClass="com.jetbrains.python.sdk.poetry.PoetryPackageVersionsInspection"
key="INSP.poetry.package.versions.display.name" bundle="messages.PyBundle" groupKey="INSP.GROUP.python"
suppressId="PoetryPackageVersions" shortName="PoetryPackageVersionsInspection"/>
<registryKey defaultValue="20" description="How often conda environments cache must be refreshed (seconds)"
key="python.conda.envs.refresh.seconds" restartRequired="true"/>
<registryKey defaultValue="60" description="TTL of conda environments cache (seconds)" key="python.conda.envs.cache.ttl.seconds"
@@ -1309,5 +1305,7 @@
implementation="com.jetbrains.python.conda.CondaDependenciesInspectionProvider"/>
<python.inspections.dependencies.dependenciesInspectionProvider
implementation="com.jetbrains.python.pipenv.PipEnvDependenciesInspectionProvider"/>
<python.inspections.dependencies.dependenciesInspectionProvider
implementation="com.jetbrains.python.sdk.poetry.LegacyPoetryDependenciesInspectionProvider"/>
</extensions>
</idea-plugin>
@@ -1284,8 +1284,6 @@ INSP.settings.bdd.behave.specific=Incorrect BDD Behave-specific definitions
INSP.settings.bdd.step.definition.arguments=Incorrect arguments in step definition functions
python.compatibility.inspection.advertiser.notifications.group.title=Python compatibility inspection recommended
python.compatibility.inspection.advertiser.notifications.title=Python versions compatibility
INSP.poetry.package.versions.display.name=Outdated Poetry package versions
INSP.uv.package.versions.display.name=Outdated uv package versions
# Both placeholders are Python versions like 2.7, 3.7, etc.
python.compatibility.inspection.advertiser.version.stale.python3.version.warning.message=\
@@ -72,15 +72,30 @@ class DependenciesInspection : LocalInspectionTool() {
return
}
val (provider, dependencies) = DependenciesInspectionProviderData.providers.firstNotNullOfOrNull { provider ->
provider.getDependencies(psiFile)?.let { provider to it }
} ?: return
val eligibleProviders =
DependenciesInspectionProviderData
.providers
.mapNotNull { provider ->
provider
.getDependencies(psiFile)
?.let { provider to it }
}
if (psiFile.injectionParent() == null) {
verifyNonEmptyFile(psiFile, provider, packageManager)
if (eligibleProviders.isEmpty()) {
return
}
packageManager.verifyPackageManager(dependencies)
val finalDependenciesMap = mutableMapOf<PyRequirement, PsiElement>()
for ((provider, dependencies) in eligibleProviders) {
if (psiFile.injectionParent() == null) {
verifyNonEmptyFile(psiFile, provider, packageManager)
}
finalDependenciesMap += dependencies
}
packageManager.verifyPackageManager(finalDependenciesMap)
}
private fun verifyNonEmptyFile(psiFile: PsiFile, provider: DependenciesInspectionProvider<*>, packageManager: PythonPackageManager) {
@@ -88,12 +103,16 @@ class DependenciesInspection : LocalInspectionTool() {
return
}
val dependenciesExporter = packageManager.dependenciesExporter
val fixes = dependenciesExporter?.let { arrayOf(ExportDependenciesQuickFix(it)) } ?: emptyArray()
val fixes =
packageManager
.dependenciesExporter
?.let { arrayOf(ExportDependenciesQuickFix(it)) }
?: emptyArray()
val inspectionMessage = provider.emptyFileInspectionMessage ?: return
holder.registerProblem(
psiFile,
provider.emptyFileInspectionMessage,
inspectionMessage,
ProblemHighlightType.GENERIC_ERROR_OR_WARNING,
*fixes,
)
@@ -13,7 +13,7 @@ typealias DependenciesMap = Map<PyRequirement, PsiElement>
@ApiStatus.Internal
abstract class DependenciesInspectionProvider<T : PsiFile>(internal val `class`: Class<T>) {
protected abstract fun provideDependencies(file: T): DependenciesMap?
abstract val emptyFileInspectionMessage: @InspectionMessage String
abstract val emptyFileInspectionMessage: @InspectionMessage String?
fun getDependencies(file: PsiFile): DependenciesMap? =
@Suppress("UNCHECKED_CAST")
+10 -1
View File
@@ -4,6 +4,15 @@ package com.jetbrains.python.psi
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import com.intellij.psi.impl.source.resolve.FileContextUtil
import org.toml.lang.psi.TomlLiteral
import org.toml.lang.psi.ext.TomlLiteralKind
import org.toml.lang.psi.ext.kind
internal fun PsiFile.injectionParent(): PsiElement? =
getUserData(FileContextUtil.INJECTED_IN_ELEMENT)?.element
getUserData(FileContextUtil.INJECTED_IN_ELEMENT)?.element
internal fun TomlLiteral.getStringOrNull(): String? =
when (val kind = kind) {
is TomlLiteralKind.String -> kind.value
else -> null
}
@@ -10,20 +10,16 @@ import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.jetbrains.python.inspections.dependencies.DependenciesMap
import com.jetbrains.python.packaging.PyRequirement
import com.jetbrains.python.packaging.PyRequirementParser
import org.jetbrains.annotations.ApiStatus
import com.jetbrains.python.psi.getStringOrNull
import org.toml.lang.psi.TomlFile
import org.toml.lang.psi.TomlInlineTable
import org.toml.lang.psi.TomlLiteral
import org.toml.lang.psi.TomlTable
import org.toml.lang.psi.ext.TomlLiteralKind
import org.toml.lang.psi.ext.getValueByKey
import org.toml.lang.psi.ext.kind
private val dependencyHeaders = setOf("packages", "dev-packages")
private const val versionKey = "version"
@ApiStatus.Internal
internal object PipEnvParser {
private val gson = Gson()
@@ -80,9 +76,3 @@ internal object PipEnvParser {
}
}
}
private fun TomlLiteral.getStringOrNull(): String? =
when (val kind = kind) {
is TomlLiteralKind.String -> kind.value
else -> null
}
@@ -0,0 +1,51 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.sdk.poetry
import com.intellij.codeInspection.util.InspectionMessage
import com.intellij.psi.PsiFile
import com.intellij.python.pyproject.PY_PROJECT_TOML
import com.jetbrains.python.inspections.dependencies.DependenciesInspectionProvider
import com.jetbrains.python.inspections.dependencies.DependenciesMap
import com.jetbrains.python.packaging.PyRequirementParser
import com.jetbrains.python.psi.getStringOrNull
import com.jetbrains.python.requirements.getPythonSdk
import org.toml.lang.psi.TomlFile
import org.toml.lang.psi.TomlKeyValue
import org.toml.lang.psi.TomlLiteral
import org.toml.lang.psi.TomlTable
private val poetryGroupRegex = Regex("""^tool\.poetry\.group\.[^.]*\.dependencies$""")
private val legacyPoetryDependencyHeaders = setOf("tool.poetry.dependencies", "tool.poetry.dev-dependencies")
internal class LegacyPoetryDependenciesInspectionProvider : DependenciesInspectionProvider<TomlFile>(TomlFile::class.java) {
override fun provideDependencies(file: TomlFile): DependenciesMap? {
if (file.name != PY_PROJECT_TOML || !isPoetryProject(file)) {
return null
}
return file
.children
.filterIsInstance<TomlTable>()
.filter {
it.header.key?.text?.let { text ->
text in legacyPoetryDependencyHeaders || poetryGroupRegex matches text
} == true
}
.flatMap { it.children.filterIsInstance<TomlKeyValue>() }
.mapNotNull { keyValue ->
val versionString =
(keyValue.value as? TomlLiteral)?.getStringOrNull()
?: return@mapNotNull null
(PyRequirementParser.fromLine("${keyValue.key.text}${versionString}")
?: PyRequirementParser.fromLine(keyValue.key.text))
?.let { pyRequirement -> pyRequirement to keyValue }
}
.toMap()
}
override val emptyFileInspectionMessage: @InspectionMessage String? = null
private fun isPoetryProject(psiFile: PsiFile) =
getPythonSdk(psiFile)?.isPoetry == true
}
@@ -1,80 +0,0 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.sdk.poetry
import com.intellij.codeInspection.LocalInspectionTool
import com.intellij.codeInspection.LocalInspectionToolSession
import com.intellij.codeInspection.ProblemHighlightType
import com.intellij.codeInspection.ProblemsHolder
import com.intellij.openapi.module.Module
import com.intellij.openapi.module.ModuleManager
import com.intellij.openapi.module.ModuleUtilCore
import com.intellij.openapi.progress.runBlockingMaybeCancellable
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiElementVisitor
import com.intellij.psi.PsiFile
import com.intellij.python.pyproject.PY_PROJECT_TOML
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.jetbrains.python.PyBundle
import com.jetbrains.python.packaging.PyPackageName
import com.jetbrains.python.packaging.management.PythonPackageManager
import com.jetbrains.python.sdk.findAmongRoots
import com.jetbrains.python.sdk.legacy.PythonSdkUtil
import org.toml.lang.psi.TomlKeyValue
import org.toml.lang.psi.TomlTable
/**
* This source code is created by @koxudaxi Koudai Aono <koxudaxi@gmail.com>
*/
internal class PoetryPackageVersionsInspection : LocalInspectionTool() {
override fun buildVisitor(
holder: ProblemsHolder,
isOnTheFly: Boolean,
session: LocalInspectionToolSession,
): PsiElementVisitor {
return PoetryFileVisitor(holder)
}
class PoetryFileVisitor(
val holder: ProblemsHolder,
) : PsiElementVisitor() {
@RequiresBackgroundThread
private fun guessModule(element: PsiElement): Module? {
return ModuleUtilCore.findModuleForPsiElement(element)
?: ModuleManager.getInstance(element.project).modules.let { if (it.size != 1) null else it[0] }
}
@RequiresBackgroundThread
private fun Module.pyProjectTomlBlocking(): VirtualFile? = runBlockingMaybeCancellable {
findAmongRoots(this@pyProjectTomlBlocking, PY_PROJECT_TOML)
}
val poetryGroupRegex = Regex("""^tool\.poetry\.group\.[^.]*\.dependencies$""")
@RequiresBackgroundThread
override fun visitFile(psiFile: PsiFile) {
val module = guessModule(psiFile) ?: return
val sdk = PythonSdkUtil.findPythonSdk(module) ?: return
if (!sdk.isPoetry) return
if (psiFile.virtualFile != module.pyProjectTomlBlocking()) return
psiFile.children
.filter { element ->
(element as? TomlTable)?.header?.key?.text?.let { key ->
key in listOf("tool.poetry.dependencies", "tool.poetry.dev-dependencies") ||
poetryGroupRegex matches key
} ?: false
}.flatMap {
it.children.mapNotNull { line -> line as? TomlKeyValue }
}.forEach { keyValue ->
val packageName = PyPackageName.normalizePackageName(keyValue.key.text)
val outdatedVersion = PythonPackageManager.forSdk(module.project, sdk).listOutdatedPackagesSnapshot()[packageName]
if (outdatedVersion != null) {
val message = PyBundle.message("python.sdk.inspection.message.version.outdated.latest",
packageName, outdatedVersion.version, outdatedVersion.latestVersion)
holder.registerProblem(keyValue, message, ProblemHighlightType.WARNING)
}
}
}
}
}
@@ -0,0 +1,18 @@
[tool.poetry]
name = "pythonproject15"
version = "0.1.0"
description = ""
readme = "README.md"
[tool.poetry.dependencies]
<weak_warning descr="'django' version 1.3.1 is outdated (latest: 5.0.0)">django = "=1.3.1"</weak_warning>
<warning descr="Package numpy is not installed">numpy = "*"</warning>
<warning descr="Package pandas is not installed">pandas = "=1.0.0"</warning>
[tool.poetry.group.dev.dependencies]
<weak_warning descr="'flask' version 1.0 is outdated (latest: 3.0.0)">flask = "=1.0"</weak_warning>
requests = "=1.22.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
@@ -60,23 +60,4 @@ class CondaDependenciesInspectionProviderTest : PythonDependencyTestCase() {
}
override fun getBasePath(): String = "/community/python/testData/conda/environmentYml/inspections/"
/**
* Marks the SDK as tracking `environment.yml` as its declared-dependencies root (associating it with
* the module dir that holds the file) so the package manager's dependency-file cache tracks it — the
* gate in [com.jetbrains.python.inspections.dependencies.DependenciesInspection] reads that cache. The
* real conda manager exposes `environment.yml` as its root unconditionally; the test manager keys this
* off SDK user data. Init is then forced so the cache is seeded before highlighting runs.
*/
private fun markEnvironmentYmlAsDependencyRoot() {
val sdk = myFixture.project.pythonSdk!!
sdk.putUserData(TestPythonPackageManager.REQUIREMENTS_PROVIDER_KEY, RequirementsProviderType.ENVIRONMENT_YML)
val moduleDir = myFixture.findFileInTempDir("environment.yml").parent
ApplicationManager.getApplication().runWriteAction {
val modificator = sdk.sdkModificator
(modificator.sdkAdditionalData as PythonSdkAdditionalData).associatedModulePath = moduleDir.path
modificator.commitChanges()
}
runBlocking { PythonPackageManager.forSdk(myFixture.project, sdk).waitForInit() }
}
}
@@ -0,0 +1,79 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.python.junit5Tests.env.poetry.legacy
import com.intellij.codeInspection.ex.InspectionProfileImpl
import com.intellij.testFramework.TestDataPath
import com.jetbrains.python.PyBundle
import com.jetbrains.python.inspections.dependencies.DependenciesInspection
import com.jetbrains.python.packaging.common.PythonOutdatedPackage
import com.jetbrains.python.packaging.common.PythonPackage
import com.jetbrains.python.packaging.management.RequirementsProviderType
import com.jetbrains.python.packaging.management.TestPackageManagerProvider
import com.jetbrains.python.requirements.PythonDependencyTestCase
import com.jetbrains.python.sdk.PythonSdkAdditionalData
import com.jetbrains.python.sdk.poetry.PyPoetrySdkAdditionalData
import com.jetbrains.python.sdk.pythonSdk
@TestDataPath($$"$CONTENT_ROOT/../testData/poetry/legacy/inspections")
internal class LegacyPoetryDependenciesInspectionProviderTest : PythonDependencyTestCase() {
fun testLegacyPoetryDependenciesInspectionProvider() {
val provider =
TestPackageManagerProvider()
.withPackageInstalled(
PythonPackage("python", "3.9", false),
PythonPackage("django", "1.3.1", false),
PythonPackage("flask", "1.0", false),
PythonPackage("requests", "1.22.0", false)
)
.withOutdatedPackages(
PythonOutdatedPackage("django", "1.3.1", "5.0.0"),
PythonOutdatedPackage("flask", "1.0", "3.0.0"),
)
initTestPackageManager(provider)
myFixture.copyDirectoryToProject(getTestName(false), "")
setDependencyRoot(RequirementsProviderType.PYPROJECT_TOML)
myFixture.configureFromTempProjectFile("pyproject.toml")
myFixture.enableInspections(DependenciesInspection::class.java)
myFixture.checkHighlighting(true, false, true, false)
for (pkg in listOf("django", "flask")) {
val offset = myFixture.editor.document.text.indexOf("$pkg =")
myFixture.editor.caretModel.moveToOffset(offset)
val availableIntentions = myFixture.availableIntentions.map { it.text }
val updateFixText = PyBundle.message("QFIX.NAME.update.requirement", pkg)
assertContainsElements(availableIntentions, updateFixText)
assertContainsElements(availableIntentions, PyBundle.message("QFIX.NAME.update.all.requirements"))
}
for (pkg in listOf("numpy")) {
val offset = myFixture.editor.document.text.indexOf("$pkg =")
myFixture.editor.caretModel.moveToOffset(offset)
val availableIntentions = myFixture.availableIntentions.map { it.text }
val installFixText = PyBundle.message("QFIX.NAME.install.requirement", pkg)
assertContainsElements(availableIntentions, installFixText)
assertContainsElements(availableIntentions, PyBundle.message("QFIX.NAME.install.all.requirements"))
}
}
override fun setUp() {
super.setUp()
InspectionProfileImpl.INIT_INSPECTIONS = true
myFixture.project.pythonSdk = projectDescriptor.sdk
}
override fun tearDown() {
InspectionProfileImpl.INIT_INSPECTIONS = false
super.tearDown()
}
override val sdkAdditionalData: PythonSdkAdditionalData
get() = PyPoetrySdkAdditionalData(null)
override fun getBasePath(): String = "/community/python/testData/poetry/legacy/inspections/"
}
@@ -6,6 +6,7 @@ import com.intellij.openapi.projectRoots.Sdk
import com.intellij.testFramework.ExtensionTestUtil
import com.intellij.testFramework.LightProjectDescriptor
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.jetbrains.env.python.testing.PythonNoseTestingTest
import com.jetbrains.python.PyNames
import com.jetbrains.python.PythonMockSdk
import com.jetbrains.python.PythonTestUtil
@@ -74,7 +75,7 @@ abstract class PythonDependencyTestCase : BasePlatformTestCase() {
"${PythonTestUtil.getTestDataPath()}/MockSdk", PythonSdkType.getInstance(), languageLevel,
*additionalRoots)
sdk.sdkModificator.let {
it.sdkAdditionalData = PythonSdkAdditionalData()
it.sdkAdditionalData = sdkAdditionalData ?: PythonSdkAdditionalData()
ApplicationManager.getApplication().runWriteAction {
it.commitChanges()
}
@@ -84,6 +85,8 @@ abstract class PythonDependencyTestCase : BasePlatformTestCase() {
}
}
open val sdkAdditionalData: PythonSdkAdditionalData? = null
override fun getBasePath(): String {
return "/community/python/testData/requirements/"
}