diff --git a/python/openapi/src/com/jetbrains/python/packaging/PyRequirement.java b/python/openapi/src/com/jetbrains/python/packaging/PyRequirement.java
index 43205910b97c..a17c27fe69f5 100644
--- a/python/openapi/src/com/jetbrains/python/packaging/PyRequirement.java
+++ b/python/openapi/src/com/jetbrains/python/packaging/PyRequirement.java
@@ -3,8 +3,8 @@ package com.jetbrains.python.packaging;
import com.intellij.openapi.util.NlsSafe;
import com.intellij.openapi.util.text.StringUtil;
-import com.intellij.openapi.vfs.VirtualFile;
import com.jetbrains.python.packaging.requirement.PyRequirementVersionSpec;
+import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -18,7 +18,6 @@ import java.util.List;
* @see PEP-440
*/
public interface PyRequirement {
-
@NotNull
String getName();
@@ -45,7 +44,7 @@ public interface PyRequirement {
* @return first package that satisfies this requirement or null.
*/
@Nullable
- PyPackage match(@NotNull Collection extends PyPackage> packages);
+ PyPackage match(@NotNull Collection packages);
boolean match(@NotNull PyPackage packageName);
@@ -63,4 +62,7 @@ public interface PyRequirement {
}
@NotNull @NlsSafe String getPresentableTextWithoutVersion();
+
+ @ApiStatus.Internal
+ @NotNull PyRequirement withVersionSpecs(@NotNull List spec);
}
diff --git a/python/pluginResources/intellij.python.community.impl.xml b/python/pluginResources/intellij.python.community.impl.xml
index a3e590a9887d..0ac9a388b11d 100644
--- a/python/pluginResources/intellij.python.community.impl.xml
+++ b/python/pluginResources/intellij.python.community.impl.xml
@@ -238,10 +238,10 @@
+ implementationClass="com.jetbrains.python.codeInsight.stubs.PyStubPackagesCompatibilityInspection"/>
+ level="WARNING" implementationClass="com.jetbrains.python.codeInsight.stubs.PyStubPackagesAdvertiser"/>
diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties
index 4912cd861cb8..f47265040669 100644
--- a/python/pluginResources/messages/PyBundle.properties
+++ b/python/pluginResources/messages/PyBundle.properties
@@ -713,11 +713,12 @@ code.insight.stub.checked.packages.are.not.installed.message=Stub {0,choice,1#pa
code.insight.stub.packages.ignored.notification.content=Suggested {0} {1,choice,1#is|2#are} incompatible with your current environment.\n\
{1,choice,1#This|2#These} stub {1,choice,1#package|2#packages} will be removed and ignored until new version is released.
code.insight.stub.packages.install.requirements.fix.name=Install stub {0,choice,1#package|2#packages}
-code.insight.type.hints.are.not.installed=Type hints are not installed
+code.insight.type.hints.are.not.installed=Install ''{0}'' to improve code insight experience.
code.insight.install.type.hints.content=They could make code insight better.
code.insight.install.type.hints.action=Install stub packages
+code.insight.install.type.hint.action=Install {0}
code.insight.ignore.type.hints=Ignore
-code.insight.ignore.packages.qfix=Ignore {0,choice, 1#package|2#packages}
+code.insight.ignore.packages.qfix=Ignore package
configurable.pipenv.auto.detected=Auto-detected: {0}
configurable.choose.path.to.the.package.requirements.file=Choose path to the package requirements file for SDK:
configurable.choose.working.directory=Choose the working directory:
diff --git a/python/python-psi-impl/resources/messages/PyPsiBundle.properties b/python/python-psi-impl/resources/messages/PyPsiBundle.properties
index c66d14466b89..93c08f396718 100644
--- a/python/python-psi-impl/resources/messages/PyPsiBundle.properties
+++ b/python/python-psi-impl/resources/messages/PyPsiBundle.properties
@@ -551,9 +551,10 @@ QFIX.add.imported.package.to.declared.packages=Add "{0}" package to requirements
INSP.pep8.ignore.base.class=Ignore Base Class
INSP.pep8.ignore.method.names.for.descendants.of.class=Ignore method names for descendants of class
-INSP.stub.packages.compatibility.ignore=Ignore ''{0}'' compatibility
+INSP.stub.packages.compatibility.ignore=Ignore
+INSP.stub.packages.compatibility.install=Install
INSP.stub.packages.compatibility.ignored.packages.label=Ignored stub packages:
-INSP.stub.packages.compatibility.incompatible.packages.message=''{0}{1}{2}'' is incompatible with ''{3}{4}{5}''. Expected ''{6}'' version: [{7}]
+INSP.stub.packages.compatibility.incompatible.packages.message=Stub ''{0}'' is incompatible with the original package.
INSP.arguments.not.declared.but.provided.by.decorator=Following arguments are not declared but provided by decorator: {0}
INSP.pep8.coding.style.violation=PEP 8 coding style violation
INSP.shadowing.names=Shadowing names from outer scopes
diff --git a/python/python-psi-impl/src/com/jetbrains/python/packaging/PyRequirementImpl.kt b/python/python-psi-impl/src/com/jetbrains/python/packaging/PyRequirementImpl.kt
index 25295a3a6f34..d990f52ec6e5 100644
--- a/python/python-psi-impl/src/com/jetbrains/python/packaging/PyRequirementImpl.kt
+++ b/python/python-psi-impl/src/com/jetbrains/python/packaging/PyRequirementImpl.kt
@@ -47,7 +47,11 @@ class PyRequirementImpl(
}
}
- override fun hashCode(): Int = 31 * name.hashCode() + versionSpecs.hashCode()
+ override fun withVersionSpecs(specs: List): PyRequirement {
+ return PyRequirementImpl(presentableName, specs, installOptions, extras)
+ }
+
+ override fun hashCode(): Int = 31 * name.hashCode() + versionSpecs.hashCode()
override fun toString(): String {
return presentableText
diff --git a/python/python-psi-impl/src/com/jetbrains/python/packaging/PyRequirements.kt b/python/python-psi-impl/src/com/jetbrains/python/packaging/PyRequirements.kt
index e6dd3c2e273f..c57a324c9bca 100644
--- a/python/python-psi-impl/src/com/jetbrains/python/packaging/PyRequirements.kt
+++ b/python/python-psi-impl/src/com/jetbrains/python/packaging/PyRequirements.kt
@@ -12,10 +12,23 @@ import com.jetbrains.python.packaging.requirement.PyRequirementVersionSpec
* @see PyRequirementParser.fromText
* @see PyRequirementParser.fromFile
*/
-fun pyRequirement(name: String, versionSpec: PyRequirementVersionSpec? = null): PyRequirement = PyRequirementImpl(name,
- listOfNotNull(versionSpec),
- listOf(name),
- "")
+
+fun pyRequirement(
+ name: String,
+ versionSpec: PyRequirementVersionSpec? = null,
+): PyRequirement = PyRequirementImpl(name,
+ listOfNotNull(versionSpec),
+ listOf(name),
+ "")
+
+fun pyRequirement(
+ name: String,
+ versionSpec: PyRequirementVersionSpec?,
+ extras: String,
+): PyRequirement = PyRequirementImpl(name,
+ listOfNotNull(versionSpec),
+ listOf(name),
+ extras)
/**
* This helper is not an API, consider using methods listed below.
diff --git a/python/src/com/jetbrains/python/codeInsight/stubs/PyStubPackagesAdvertiser.kt b/python/src/com/jetbrains/python/codeInsight/stubs/PyStubPackagesAdvertiser.kt
new file mode 100644
index 000000000000..04202a8558e7
--- /dev/null
+++ b/python/src/com/jetbrains/python/codeInsight/stubs/PyStubPackagesAdvertiser.kt
@@ -0,0 +1,25 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.codeInsight.stubs
+
+import com.intellij.codeInspection.LocalInspectionToolSession
+import com.intellij.codeInspection.ProblemsHolder
+import com.intellij.codeInspection.options.OptPane
+import com.intellij.psi.PsiElementVisitor
+import com.jetbrains.python.PyPsiBundle
+import com.jetbrains.python.codeInsight.stubs.visitors.PyStubAdvertiserVisitor
+import com.jetbrains.python.inspections.PyInspection
+import org.jetbrains.annotations.ApiStatus
+
+@ApiStatus.Internal
+class PyStubPackagesAdvertiser : PyInspection() {
+ var ignoredPackages: MutableList = mutableListOf()
+
+ override fun getOptionsPane(): OptPane =
+ OptPane.pane(OptPane.stringList("ignoredPackages", PyPsiBundle.message("INSP.stub.packages.compatibility.ignored.packages.label")))
+
+ override fun buildVisitor(
+ holder: ProblemsHolder,
+ isOnTheFly: Boolean,
+ session: LocalInspectionToolSession,
+ ): PsiElementVisitor = PyStubAdvertiserVisitor(ignoredPackages, holder, session)
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/codeInsight/stubs/PyStubPackagesCompatibilityInspection.kt b/python/src/com/jetbrains/python/codeInsight/stubs/PyStubPackagesCompatibilityInspection.kt
new file mode 100644
index 000000000000..e08e7becba9f
--- /dev/null
+++ b/python/src/com/jetbrains/python/codeInsight/stubs/PyStubPackagesCompatibilityInspection.kt
@@ -0,0 +1,26 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.codeInsight.stubs
+
+import com.intellij.codeInspection.LocalInspectionToolSession
+import com.intellij.codeInspection.ProblemsHolder
+import com.intellij.codeInspection.options.OptPane
+import com.intellij.psi.PsiElementVisitor
+import com.jetbrains.python.PyPsiBundle
+import com.jetbrains.python.codeInsight.stubs.visitors.PyIncompatibleStubVisitor
+import com.jetbrains.python.inspections.PyInspection
+
+class PyStubPackagesCompatibilityInspection : PyInspection() {
+ @Suppress("MemberVisibilityCanBePrivate")
+ var ignoredStubPackages: MutableList = mutableListOf()
+
+ override fun getOptionsPane(): OptPane =
+ OptPane.pane(OptPane.stringList("ignoredStubPackages", PyPsiBundle.message("INSP.stub.packages.compatibility.ignored.packages.label")))
+
+ override fun buildVisitor(
+ holder: ProblemsHolder,
+ isOnTheFly: Boolean,
+ session: LocalInspectionToolSession,
+ ): PsiElementVisitor {
+ return PyIncompatibleStubVisitor(ignoredStubPackages, holder, session)
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/codeInsight/stubs/PyStubsSuggestions.kt b/python/src/com/jetbrains/python/codeInsight/stubs/PyStubsSuggestions.kt
new file mode 100644
index 000000000000..2d8c96fc387f
--- /dev/null
+++ b/python/src/com/jetbrains/python/codeInsight/stubs/PyStubsSuggestions.kt
@@ -0,0 +1,21 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.codeInsight.stubs
+
+import com.jetbrains.python.packaging.common.NormalizedPythonPackageName
+import com.jetbrains.python.packaging.pyRequirement
+import com.jetbrains.python.packaging.requirement.PyRequirementRelation
+
+internal object PyStubsSuggestions {
+ //relation null if stubs should be the latest
+ val SUPPORTED_STUBS = mapOf(
+ NormalizedPythonPackageName.from("docutils") to (pyRequirement("types-docutils") to PyRequirementRelation.COMPATIBLE),
+ NormalizedPythonPackageName.from("PyGObject") to (pyRequirement("PyGObject-stubs") to null),
+ NormalizedPythonPackageName.from("PyQt5") to (pyRequirement("PyQt5-stubs") to PyRequirementRelation.COMPATIBLE),
+ NormalizedPythonPackageName.from("pandas") to (pyRequirement("pandas-stubs") to PyRequirementRelation.COMPATIBLE),
+ NormalizedPythonPackageName.from("celery") to (pyRequirement("celery-types") to null),
+ NormalizedPythonPackageName.from("boto3") to (pyRequirement("boto3-stubs") to PyRequirementRelation.COMPATIBLE),
+ NormalizedPythonPackageName.from("scipy") to (pyRequirement("scipy-stubs") to PyRequirementRelation.COMPATIBLE),
+ NormalizedPythonPackageName.from("traits") to (pyRequirement("traits-stubs") to null),
+ NormalizedPythonPackageName.from("djangorestframework") to (pyRequirement("djangorestframework-stubs") to null),
+ )
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/codeInsight/stubs/checkers/PyNotInstalledStubsChecker.kt b/python/src/com/jetbrains/python/codeInsight/stubs/checkers/PyNotInstalledStubsChecker.kt
new file mode 100644
index 000000000000..14d10a785a1a
--- /dev/null
+++ b/python/src/com/jetbrains/python/codeInsight/stubs/checkers/PyNotInstalledStubsChecker.kt
@@ -0,0 +1,38 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.codeInsight.stubs.checkers
+
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.project.Project
+import com.jetbrains.python.codeInsight.stubs.PyStubsSuggestions
+import com.jetbrains.python.packaging.management.PythonPackageManager
+import com.jetbrains.python.packaging.management.getInstalledPackage
+import com.jetbrains.python.packaging.pyRequirementVersionSpec
+
+@Service(Service.Level.PROJECT)
+internal class PyNotInstalledStubsChecker(project: Project) : PyStubsChecker(project) {
+ override suspend fun detectSuggestedStubs(packageManager: PythonPackageManager): List {
+ return PyStubsSuggestions.SUPPORTED_STUBS.mapNotNull { (packageName, stub) ->
+ val stubRequirement = stub.first
+ val stubRelationToPackage = stub.second
+ val installedPackage = packageManager.getInstalledPackage(packageName.name) ?: return@mapNotNull null
+ val installedStubPackage = packageManager.getInstalledPackage(stubRequirement.name)
+ if (installedStubPackage != null)
+ return@mapNotNull null
+
+ if (stubRelationToPackage == null)
+ return@mapNotNull PyPackageStubLink(packageName, stubRequirement)
+
+ val expectedVersionSpec = pyRequirementVersionSpec(stubRelationToPackage, installedPackage.version)
+ val expectedRequirement = stubRequirement.withVersionSpecs(listOf(expectedVersionSpec))
+
+ val isStubExists = packageManager.repositoryManager.matchRequirement(expectedRequirement)
+ if (!isStubExists)
+ return@mapNotNull null
+ PyPackageStubLink(packageName, expectedRequirement)
+ }
+ }
+
+ companion object {
+ fun getInstance(project: Project): PyNotInstalledStubsChecker = project.getService(PyNotInstalledStubsChecker::class.java)
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/codeInsight/stubs/checkers/PyPackageStubLink.kt b/python/src/com/jetbrains/python/codeInsight/stubs/checkers/PyPackageStubLink.kt
new file mode 100644
index 000000000000..b6e015b300dc
--- /dev/null
+++ b/python/src/com/jetbrains/python/codeInsight/stubs/checkers/PyPackageStubLink.kt
@@ -0,0 +1,7 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.codeInsight.stubs.checkers
+
+import com.jetbrains.python.packaging.PyRequirement
+import com.jetbrains.python.packaging.common.NormalizedPythonPackageName
+
+internal data class PyPackageStubLink(val packageName: NormalizedPythonPackageName, val stubRequirement: PyRequirement)
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/codeInsight/stubs/checkers/PyStubsChecker.kt b/python/src/com/jetbrains/python/codeInsight/stubs/checkers/PyStubsChecker.kt
new file mode 100644
index 000000000000..fa011dfac842
--- /dev/null
+++ b/python/src/com/jetbrains/python/codeInsight/stubs/checkers/PyStubsChecker.kt
@@ -0,0 +1,56 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.codeInsight.stubs.checkers
+
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.progress.runBlockingMaybeCancellable
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.projectRoots.Sdk
+import com.intellij.openapi.util.Key
+import com.jetbrains.python.packaging.common.PythonPackageManagementListener
+import com.jetbrains.python.packaging.management.PythonPackageManager
+import com.jetbrains.python.packaging.utils.PyPackageCoroutine
+import com.jetbrains.python.sdk.PythonSdkUtil
+import com.jetbrains.python.statistics.sdks
+
+internal abstract class PyStubsChecker(val project: Project) : Disposable.Default {
+ val key: Key> = Key(this::class.java.name)
+
+ init {
+ project.messageBus.connect(this).subscribe(PythonPackageManager.PACKAGE_MANAGEMENT_TOPIC, object : PythonPackageManagementListener {
+ override fun packagesChanged(sdk: Sdk) {
+ PyPackageCoroutine.launch(project) {
+ checkSdk(sdk)
+ }
+ }
+
+ })
+
+ project.sdks.filter { PythonSdkUtil.isPythonSdk(it) }.forEach { startCheckForSdk(it) }
+ }
+
+ fun getCached(sdk: Sdk): Set = sdk.getUserData(key) ?: emptySet()
+
+ protected abstract suspend fun detectSuggestedStubs(packageManager: PythonPackageManager): List
+
+
+ private fun startCheckForSdk(sdk: Sdk) {
+ if (sdk.getUserData(key) != null) return
+
+ if (ApplicationManager.getApplication().isUnitTestMode) {
+ runBlockingMaybeCancellable {
+ checkSdk(sdk)
+ }
+ }
+ else {
+ PyPackageCoroutine.launch(project) {
+ checkSdk(sdk)
+ }
+ }
+ }
+
+ private suspend fun checkSdk(sdk: Sdk) {
+ val suggested = detectSuggestedStubs(PythonPackageManager.forSdk(project, sdk)).toSet()
+ sdk.putUserData(key, suggested)
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/codeInsight/stubs/checkers/PyStubsIncompatibilityChecker.kt b/python/src/com/jetbrains/python/codeInsight/stubs/checkers/PyStubsIncompatibilityChecker.kt
new file mode 100644
index 000000000000..6769f8824000
--- /dev/null
+++ b/python/src/com/jetbrains/python/codeInsight/stubs/checkers/PyStubsIncompatibilityChecker.kt
@@ -0,0 +1,41 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.codeInsight.stubs.checkers
+
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.project.Project
+import com.jetbrains.python.codeInsight.stubs.PyStubsSuggestions
+import com.jetbrains.python.packaging.management.PythonPackageManager
+import com.jetbrains.python.packaging.management.getInstalledPackage
+import com.jetbrains.python.packaging.pyRequirementVersionSpec
+
+@Service(Service.Level.PROJECT)
+internal class PyStubsIncompatibilityChecker(project: Project) : PyStubsChecker(project) {
+ override suspend fun detectSuggestedStubs(packageManager: PythonPackageManager): List {
+ return PyStubsSuggestions.SUPPORTED_STUBS.mapNotNull { (packageName, stub) ->
+ val stubRequirement = stub.first
+ val stubRelationToPackage = stub.second
+ val installedPackage = packageManager.getInstalledPackage(packageName.name) ?: return@mapNotNull null
+ val installedStubPackage = packageManager.getInstalledPackage(stubRequirement.name) ?: return@mapNotNull null
+ if (stubRelationToPackage == null)
+ return@mapNotNull null
+ val installedStubVersion = installedStubPackage.version
+ val expectedVersionSpec = pyRequirementVersionSpec(stubRelationToPackage, installedPackage.version)
+ if (expectedVersionSpec.matches(installedStubVersion))
+ return@mapNotNull null
+
+ val availableStubVersions = packageManager.repositoryManager.getVersions(stubRequirement.name, null)
+ ?: emptyList()
+ val isExistsSupportedStubVersion = availableStubVersions.any { expectedVersionSpec.matches(it) }
+ if (!isExistsSupportedStubVersion) {
+ return@mapNotNull null
+ }
+
+ val expectedRequirement = stubRequirement.withVersionSpecs(listOf(expectedVersionSpec))
+ PyPackageStubLink(packageName, expectedRequirement)
+ }
+ }
+
+ companion object {
+ fun getInstance(project: Project): PyStubsIncompatibilityChecker = project.getService(PyStubsIncompatibilityChecker::class.java)
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/codeInsight/stubs/fixes/IgnoreStubAdvertiseQuickFix.kt b/python/src/com/jetbrains/python/codeInsight/stubs/fixes/IgnoreStubAdvertiseQuickFix.kt
new file mode 100644
index 000000000000..5cbe974bcf9c
--- /dev/null
+++ b/python/src/com/jetbrains/python/codeInsight/stubs/fixes/IgnoreStubAdvertiseQuickFix.kt
@@ -0,0 +1,19 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.codeInsight.stubs.fixes
+
+import com.intellij.codeInspection.LocalQuickFix
+import com.intellij.codeInspection.ProblemDescriptor
+import com.intellij.openapi.project.Project
+import com.intellij.profile.codeInspection.ProjectInspectionProfileManager
+import com.jetbrains.python.PyPsiBundle
+import com.jetbrains.python.packaging.common.NormalizedPythonPackageName
+
+@Suppress("ActionIsNotPreviewFriendly")
+internal class IgnoreStubAdvertiseQuickFix(val packageName: NormalizedPythonPackageName, val ignoredPackages: MutableList) : LocalQuickFix {
+ override fun getFamilyName() = PyPsiBundle.message("INSP.stub.packages.compatibility.ignore")
+
+ override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
+ if (ignoredPackages.add(packageName.name))
+ ProjectInspectionProfileManager.getInstance(project).fireProfileChanged()
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/codeInsight/stubs/fixes/IgnoreStubCompatibilityQuickFix.kt b/python/src/com/jetbrains/python/codeInsight/stubs/fixes/IgnoreStubCompatibilityQuickFix.kt
new file mode 100644
index 000000000000..2d7f0ba14d73
--- /dev/null
+++ b/python/src/com/jetbrains/python/codeInsight/stubs/fixes/IgnoreStubCompatibilityQuickFix.kt
@@ -0,0 +1,18 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.codeInsight.stubs.fixes
+
+import com.intellij.codeInspection.LocalQuickFix
+import com.intellij.codeInspection.ProblemDescriptor
+import com.intellij.openapi.project.Project
+import com.intellij.profile.codeInspection.ProjectInspectionProfileManager
+import com.jetbrains.python.PyPsiBundle
+import com.jetbrains.python.packaging.PyRequirement
+
+@Suppress("ActionIsNotPreviewFriendly")
+internal class IgnoreStubCompatibilityQuickFix(val stubRequirement: PyRequirement, val ignoredStubPkgs: MutableList) : LocalQuickFix {
+ override fun getFamilyName() = PyPsiBundle.message("INSP.stub.packages.compatibility.ignore")
+
+ override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
+ if (ignoredStubPkgs.add(stubRequirement.presentableText)) ProjectInspectionProfileManager.getInstance(project).fireProfileChanged()
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/codeInsight/stubs/fixes/InstallStubQuickFix.kt b/python/src/com/jetbrains/python/codeInsight/stubs/fixes/InstallStubQuickFix.kt
new file mode 100644
index 000000000000..5d3c48677ff2
--- /dev/null
+++ b/python/src/com/jetbrains/python/codeInsight/stubs/fixes/InstallStubQuickFix.kt
@@ -0,0 +1,23 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.codeInsight.stubs.fixes
+
+import com.intellij.codeInspection.LocalQuickFix
+import com.intellij.codeInspection.ProblemDescriptor
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.projectRoots.Sdk
+import com.jetbrains.python.PyPsiBundle
+import com.jetbrains.python.packaging.PyRequirement
+import com.jetbrains.python.packaging.management.ui.PythonPackageManagerUI
+import com.jetbrains.python.packaging.management.ui.installPyRequirementsBackground
+import com.jetbrains.python.packaging.utils.PyPackageCoroutine
+
+@Suppress("ActionIsNotPreviewFriendly")
+internal class InstallStubQuickFix(private val stub: PyRequirement, private val sdk: Sdk) : LocalQuickFix {
+ override fun getFamilyName() = PyPsiBundle.message("INSP.stub.packages.compatibility.install")
+
+ override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
+ PyPackageCoroutine.launch(project) {
+ PythonPackageManagerUI.forSdk(project, sdk).installPyRequirementsBackground(listOf(stub))
+ }
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/codeInsight/stubs/visitors/PyIncompatibleStubVisitor.kt b/python/src/com/jetbrains/python/codeInsight/stubs/visitors/PyIncompatibleStubVisitor.kt
new file mode 100644
index 000000000000..7bd1117612d4
--- /dev/null
+++ b/python/src/com/jetbrains/python/codeInsight/stubs/visitors/PyIncompatibleStubVisitor.kt
@@ -0,0 +1,30 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.codeInsight.stubs.visitors
+
+import com.intellij.codeInspection.LocalInspectionToolSession
+import com.intellij.codeInspection.ProblemsHolder
+import com.jetbrains.python.PyPsiBundle
+import com.jetbrains.python.codeInsight.stubs.checkers.PyStubsIncompatibilityChecker
+import com.jetbrains.python.codeInsight.stubs.fixes.IgnoreStubCompatibilityQuickFix
+import com.jetbrains.python.codeInsight.stubs.fixes.InstallStubQuickFix
+import com.jetbrains.python.packaging.management.PythonPackageManager
+import com.jetbrains.python.psi.PyFile
+
+internal class PyIncompatibleStubVisitor(
+ val ignoredStubPackages: MutableList,
+ holder: ProblemsHolder,
+ val session: LocalInspectionToolSession,
+) : PyStubVisitor(holder, session) {
+ override fun checkImports(file: PyFile, importedPackages: Set, packageManager: PythonPackageManager) {
+ val checker = PyStubsIncompatibilityChecker.getInstance(project = packageManager.project)
+ val cached = checker.getCached(packageManager.sdk)
+ val stubs = cached.filter { it.stubRequirement.presentableText !in ignoredStubPackages && it.packageName.name in importedPackages }
+ for (stub in stubs) {
+ val message = PyPsiBundle.message("INSP.stub.packages.compatibility.incompatible.packages.message", stub.stubRequirement.name)
+ registerProblem(file,
+ message,
+ InstallStubQuickFix(stub.stubRequirement, packageManager.sdk),
+ IgnoreStubCompatibilityQuickFix(stub.stubRequirement, ignoredStubPackages))
+ }
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/codeInsight/stubs/visitors/PyStubAdvertiserVisitor.kt b/python/src/com/jetbrains/python/codeInsight/stubs/visitors/PyStubAdvertiserVisitor.kt
new file mode 100644
index 000000000000..d4b1ad5c358f
--- /dev/null
+++ b/python/src/com/jetbrains/python/codeInsight/stubs/visitors/PyStubAdvertiserVisitor.kt
@@ -0,0 +1,31 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.codeInsight.stubs.visitors
+
+import com.intellij.codeInspection.LocalInspectionToolSession
+import com.intellij.codeInspection.ProblemsHolder
+import com.jetbrains.python.PyBundle
+import com.jetbrains.python.codeInsight.stubs.checkers.PyNotInstalledStubsChecker
+import com.jetbrains.python.codeInsight.stubs.fixes.IgnoreStubAdvertiseQuickFix
+import com.jetbrains.python.codeInsight.stubs.fixes.InstallStubQuickFix
+import com.jetbrains.python.packaging.management.PythonPackageManager
+import com.jetbrains.python.psi.PyFile
+
+
+internal class PyStubAdvertiserVisitor(
+ val ignoredPackages: MutableList,
+ holder: ProblemsHolder,
+ val session: LocalInspectionToolSession,
+) : PyStubVisitor(holder, session) {
+ override fun checkImports(file: PyFile, importedPackages: Set, packageManager: PythonPackageManager) {
+ val checker = PyNotInstalledStubsChecker.getInstance(project = packageManager.project)
+ val cached = checker.getCached(packageManager.sdk)
+ val stubs = cached.filter { it.packageName.name in importedPackages && it.packageName.name !in ignoredPackages }
+ for (stub in stubs) {
+ val message = PyBundle.message("code.insight.type.hints.are.not.installed", stub.stubRequirement.name)
+ registerProblem(file,
+ message,
+ InstallStubQuickFix(stub.stubRequirement, packageManager.sdk),
+ IgnoreStubAdvertiseQuickFix(stub.packageName, ignoredPackages))
+ }
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/codeInsight/stubs/visitors/PyStubVisitor.kt b/python/src/com/jetbrains/python/codeInsight/stubs/visitors/PyStubVisitor.kt
new file mode 100644
index 000000000000..a115dad38e03
--- /dev/null
+++ b/python/src/com/jetbrains/python/codeInsight/stubs/visitors/PyStubVisitor.kt
@@ -0,0 +1,41 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.jetbrains.python.codeInsight.stubs.visitors
+
+import com.intellij.codeInspection.LocalInspectionToolSession
+import com.intellij.codeInspection.ProblemsHolder
+import com.intellij.openapi.module.ModuleUtilCore
+import com.jetbrains.python.PyPsiPackageUtil
+import com.jetbrains.python.inspections.PyInspectionVisitor
+import com.jetbrains.python.packaging.management.PythonPackageManager
+import com.jetbrains.python.psi.PyFile
+import com.jetbrains.python.sdk.PythonSdkUtil
+
+internal abstract class PyStubVisitor(
+ holder: ProblemsHolder,
+ session: LocalInspectionToolSession,
+) : PyInspectionVisitor(holder, getContext(session)) {
+ override fun visitPyFile(file: PyFile) {
+ val module = ModuleUtilCore.findModuleForFile(file) ?: return
+ val sdk = PythonSdkUtil.findPythonSdk(module) ?: return
+
+ val importedPackages = loadImportedPackages(file).ifEmpty { null } ?: return
+ val packageManager = PythonPackageManager.forSdk(module.project, sdk)
+
+ checkImports(file, importedPackages, packageManager)
+ }
+
+ protected abstract fun checkImports(file: PyFile, importedPackages: Set, packageManager: PythonPackageManager)
+
+ private fun loadImportedPackages(file: PyFile): Set {
+ val sources = mutableSetOf()
+ file.fromImports.mapNotNullTo(sources) { it.importSourceQName?.firstComponent }
+ file.importTargets.mapNotNullTo(sources) { it.importedQName?.firstComponent }
+
+
+ val importedPackages = sources.map {
+ PyPsiPackageUtil.moduleToPackageName(it, it)
+ }.toSet()
+
+ return importedPackages.toSet()
+ }
+}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/codeInsight/typing/PyStubPackagesAdvertiser.kt b/python/src/com/jetbrains/python/codeInsight/typing/PyStubPackagesAdvertiser.kt
deleted file mode 100644
index c03dffb3dbac..000000000000
--- a/python/src/com/jetbrains/python/codeInsight/typing/PyStubPackagesAdvertiser.kt
+++ /dev/null
@@ -1,353 +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.codeInsight.typing
-
-import com.google.common.cache.Cache
-import com.intellij.codeInspection.*
-import com.intellij.codeInspection.ex.EditInspectionToolsSettingsAction
-import com.intellij.codeInspection.ex.ProblemDescriptorImpl
-import com.intellij.codeInspection.options.OptPane.pane
-import com.intellij.codeInspection.options.OptPane.stringList
-import com.intellij.execution.ExecutionException
-import com.intellij.notification.Notification
-import com.intellij.notification.NotificationAction
-import com.intellij.notification.NotificationGroupManager
-import com.intellij.notification.NotificationType
-import com.intellij.openapi.application.ApplicationManager
-import com.intellij.openapi.components.service
-import com.intellij.openapi.module.Module
-import com.intellij.openapi.module.ModuleUtilCore
-import com.intellij.openapi.project.Project
-import com.intellij.openapi.projectRoots.Sdk
-import com.intellij.openapi.util.Key
-import com.intellij.profile.codeInspection.ProjectInspectionProfileManager
-import com.intellij.psi.PsiElementVisitor
-import com.intellij.psi.util.QualifiedName
-import com.jetbrains.python.PyBundle
-import com.jetbrains.python.PyPsiBundle
-import com.jetbrains.python.codeInsight.typing.PyStubPackagesAdvertiserCache.Companion.StubPackagesForSource
-import com.jetbrains.python.inspections.PyInspection
-import com.jetbrains.python.inspections.PyInspectionVisitor
-import com.jetbrains.python.inspections.quickfix.PyInstallRequirementsFix
-import com.jetbrains.python.inspections.requirement.RunningPackagingTasksListener
-import com.jetbrains.python.packaging.*
-import com.jetbrains.python.packaging.management.PythonPackageManager
-import com.jetbrains.python.packaging.requirement.PyRequirementRelation
-import com.jetbrains.python.psi.PyFile
-import com.jetbrains.python.psi.PyReferenceExpression
-import com.jetbrains.python.sdk.PythonSdkUtil
-
-private class PyStubPackagesAdvertiser : PyInspection() {
- companion object {
- // file-level suggestion will be shown for packages below
- private val FORCED = emptyMap() // top-level package to package on PyPI
-
- // notification will be shown for packages below
- private val CHECKED = mapOf("docutils" to "docutils",
- "gi" to "PyGObject",
- "PyQt5" to "PyQt5",
- "pandas" to "pandas",
- "celery" to "celery",
- "boto3" to "boto3",
- "scipy" to "scipy",
- "traits" to "traits",
- "rest_framework" to "djangorestframework") // top-level package to package on PyPI, sorted by the latter
-
- private val EXTRAS = mapOf("boto3-stubs" to "[full]")
-
- private val IGNORE = setOf(
- "types-boto3", // duplicate of boto3-stubs
- "celery-stubs", // deprecated
- )
-
- private val BALLOON_SHOWING = Key.create("showingStubPackagesAdvertiserBalloon")
- }
-
- var ignoredPackages: MutableList = mutableListOf()
-
- override fun getOptionsPane() =
- pane(stringList("ignoredPackages", PyPsiBundle.message("INSP.stub.packages.compatibility.ignored.packages.label")))
-
- override fun buildVisitor(holder: ProblemsHolder,
- isOnTheFly: Boolean,
- session: LocalInspectionToolSession): PsiElementVisitor = Visitor(ignoredPackages, holder, session)
-
- private class Visitor(private val ignoredPackages: MutableList,
- holder: ProblemsHolder,
- session: LocalInspectionToolSession) : PyInspectionVisitor(holder, getContext(session)) {
-
- private val BALLOON_NOTIFICATIONS
- get() = NotificationGroupManager.getInstance().getNotificationGroup("Python Stub Packages Advertiser")
-
- override fun visitPyFile(node: PyFile) {
- super.visitPyFile(node)
-
- val sources = mutableSetOf()
-
- node.fromImports.mapNotNullTo(sources) { topLevelPackagesWithoutStubs(it.importSource, it.importSourceQName) }
- node.importTargets.mapNotNullTo(sources) { topLevelPackagesWithoutStubs(it.importReferenceExpression, it.importedQName) }
-
- if (sources.isNotEmpty()) {
- run(node, sources)
- }
- }
-
- private fun topLevelPackagesWithoutStubs(ref: PyReferenceExpression?, qName: QualifiedName?): String? {
- if (qName == null) return null
-
- if (ref != null &&
- ref.getReference(resolveContext).multiResolve(false).asSequence().mapNotNull { it.element }.any { isInStubPackage(it) }) {
- return null
- }
-
- return qName.firstComponent
- }
-
- private fun run(file: PyFile, sources: Set) {
- val module = ModuleUtilCore.findModuleForFile(file) ?: return
- val sdk = PythonSdkUtil.findPythonSdk(module) ?: return
-
- val packageManager = PythonPackageManager.forSdk(module.project, sdk)
- val installedPackages = packageManager.listInstalledPackagesSnapshot()
- if (installedPackages.isEmpty()) return
-
- val packageManagementService = PyPackageManagers.getInstance().getManagementService(file.project, sdk)
- val availablePackages = packageManagementService.allPackagesCached
- if (availablePackages.isEmpty()) return
-
- val ignoredStubPackages = (IGNORE + ignoredPackages).mapNotNull { PyRequirementParser.fromLine(it) }
- val cache = ApplicationManager.getApplication().service().forSdk(sdk)
-
- val forcedToLoad = processForcedPackages(file, sources, module, sdk, ignoredStubPackages, cache)
- val checkedToLoad = processCheckedPackages(file, sources, module, sdk, ignoredStubPackages, cache)
-
- loadStubPackagesForSources(
- forcedToLoad + checkedToLoad,
- FORCED + CHECKED,
- installedPackages,
- availablePackages,
- packageManagementService,
- sdk
- )
- }
-
- private fun processForcedPackages(
- file: PyFile,
- sources: Set,
- module: Module,
- sdk: Sdk,
- ignoredStubPackages: List,
- cache: Cache
- ): Set {
- val (sourcesToLoad, cached) = splitIntoNotCachedAndCached(forcedSourcesToProcess(sources), cache)
-
- val (reqs, args) = toRequirementsAndExtraArgs(cached, ignoredStubPackages)
- if (reqs.isNotEmpty()) {
- val reqsToString = PyPackageUtil.requirementsToString(reqs)
- val message = PyBundle.message("code.insight.stub.forced.packages.are.not.installed.message", reqs.size, reqsToString)
-
- registerProblem(file,
- message,
- createInstallStubPackagesQuickFix(reqs, args, module, sdk),
- createIgnorePackagesQuickFix(reqs))
- }
-
- return sourcesToLoad
- }
-
- private fun processCheckedPackages(
- file: PyFile,
- sources: Set,
- module: Module,
- sdk: Sdk,
- ignoredStubPackages: List,
- cache: Cache
- ): Set {
- val project = file.project
- if (project.getUserData(BALLOON_SHOWING) == true) return emptySet()
-
- val (sourcesToLoad, cached) = splitIntoNotCachedAndCached(checkedSourcesToProcess(sources), cache)
-
- val (unfilteredReqs, args) = toRequirementsAndExtraArgs(cached, ignoredStubPackages)
-
- val status = file.project.service()
-
- val reqs = unfilteredReqs.filterNot { status.markedAsInstalling(it.name) }
-
- if (reqs.isNotEmpty()) {
- val plural = reqs.size > 1
- val reqsToString = PyPackageUtil.requirementsToString(reqs)
-
- project.putUserData(BALLOON_SHOWING, true)
-
- val descriptionTemplate = PyBundle.message("code.insight.stub.checked.packages.are.not.installed.message", reqs.size, reqsToString)
- val problemDescriptor = ProblemDescriptorImpl(
- file,
- file,
- descriptionTemplate,
- LocalQuickFix.EMPTY_ARRAY,
- ProblemHighlightType.GENERIC_ERROR_OR_WARNING,
- true,
- null,
- true
- )
-
- BALLOON_NOTIFICATIONS
- .createNotification(
- PyBundle.message("code.insight.type.hints.are.not.installed"),
- PyBundle.message("code.insight.install.type.hints.content"),
- NotificationType.INFORMATION)
- .setSuggestionType(true)
- .addAction(
- NotificationAction.createSimpleExpiring(
- if (plural) PyBundle.message("code.insight.install.type.hints.action")
- else "${PyBundle.message("python.packaging.install")} $reqsToString"
- ) { createInstallStubPackagesQuickFix(reqs, args, module, sdk).applyFix(project, problemDescriptor) }
- )
- .addAction(
- NotificationAction.createSimpleExpiring(PyBundle.message("code.insight.ignore.type.hints")) {
- createIgnorePackagesQuickFix(reqs).applyFix(project, problemDescriptor)
- }
- )
- .addAction(
- NotificationAction.createSimpleExpiring(PyBundle.message("notification.action.edit.settings")) {
- val profile = ProjectInspectionProfileManager.getInstance(project).currentProfile
- EditInspectionToolsSettingsAction.editToolSettings(project, profile, PyStubPackagesAdvertiser::class.simpleName)
- }
- )
- .setCollapseDirection(Notification.CollapseActionsDirection.KEEP_LEFTMOST)
- .whenExpired { project.putUserData(BALLOON_SHOWING, false) }
- .notify(project)
- }
-
- return sourcesToLoad
- }
-
- private fun forcedSourcesToProcess(sources: Set) = sources.filterTo(mutableSetOf()) { it in FORCED }
-
- private fun checkedSourcesToProcess(sources: Set) = sources.filterTo(mutableSetOf()) { it in CHECKED }
-
- private fun splitIntoNotCachedAndCached(sources: Set,
- cache: Cache): Pair, List> {
- if (sources.isEmpty()) return emptySet() to emptyList()
-
- val notCached = mutableSetOf()
- val cached = mutableListOf()
-
- synchronized(cache) {
- // despite cache is thread-safe,
- // here we have sync block to guarantee only one reader
- // and as a result not run processing for sources that are already evaluating
-
- sources.forEach { source ->
- cache.getIfPresent(source).let {
- if (it == null) {
- notCached.add(source)
-
- // mark this source as evaluating
- // if source processing failed, this value would mean that such source was handled
- cache.put(source, StubPackagesForSource.EMPTY)
- }
- else {
- cached.add(it)
- }
- }
- }
- }
-
- return notCached to cached
- }
-
- private fun toRequirementsAndExtraArgs(cached: List,
- ignoredStubPackages: List): Pair, List> {
- if (cached.isEmpty()) return emptyList() to emptyList()
-
- val requirements = cached.asSequence()
- .flatMap { it.packages.entries.asSequence() }
- .filterNot { isIgnoredStubPackage(it.key, it.value.first, ignoredStubPackages) }
- .map {
- pyRequirement(it.key, PyRequirementRelation.EQ, it.value.first, extras = EXTRAS.getOrDefault(it.key, ""))
- }
- .toList()
- if (requirements.isEmpty()) return emptyList() to emptyList()
-
- val args = sequenceOf("--no-deps") +
- cached.asSequence().flatMap { pkgs -> pkgs.packages.values.asSequence().map { it.second }.flatten() }
- return requirements to args.toList()
- }
-
- private fun createInstallStubPackagesQuickFix(
- reqs: List,
- args: List,
- module: Module,
- sdk: Sdk,
- ): LocalQuickFix {
- val project = module.project
- val stubPkgNamesToInstall = reqs.mapTo(mutableSetOf()) { it.name }
-
- val installationListener = object : RunningPackagingTasksListener(module) {
- override fun started() {
- project.service().markAsInstalling(stubPkgNamesToInstall)
- }
-
- override fun finished(exceptions: List) {
- val status = project.service()
-
- val stubPkgsToUninstall = PyStubPackagesCompatibilityInspection
- .findIncompatibleRuntimeToStubPackages(sdk) { it.name in stubPkgNamesToInstall }
- .map { it.second }
-
- if (stubPkgsToUninstall.isNotEmpty()) {
- val stubPkgNamesToUninstall = stubPkgsToUninstall.mapTo(mutableSetOf()) { it.name }
-
- val uninstallationListener = object : PyPackageManagerUI.Listener {
- override fun started() {}
-
- override fun finished(exceptions: MutableList?) {
- status.unmarkAsInstalling(stubPkgNamesToUninstall)
- val reqsToIgnore = stubPkgsToUninstall.map { pyRequirement(it.name, PyRequirementRelation.EQ, it.version) }
- addStubPackagesToIgnore(reqsToIgnore, stubPkgNamesToUninstall, project)
- }
- }
-
- val content = PyBundle.message("code.insight.stub.packages.ignored.notification.content",
- stubPkgNamesToUninstall.joinToString { "'$it'" }, stubPkgNamesToUninstall.size)
-
- BALLOON_NOTIFICATIONS.createNotification(content, NotificationType.WARNING).notify(project)
- PyPackageManagerUI(project, sdk, uninstallationListener).uninstall(stubPkgsToUninstall)
-
- stubPkgNamesToInstall.removeAll(stubPkgNamesToUninstall)
- }
-
- status.unmarkAsInstalling(stubPkgNamesToInstall)
- }
- }
-
- val name = PyBundle.message("code.insight.stub.packages.install.requirements.fix.name", reqs.size)
- return PyInstallRequirementsFix(name, sdk, reqs, args, installationListener)
- }
-
- private fun createIgnorePackagesQuickFix(reqs: List): LocalQuickFix {
- return object : LocalQuickFix {
- override fun getFamilyName() = PyBundle.message("code.insight.ignore.packages.qfix", reqs.size)
-
- override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
- this@Visitor.addStubPackagesToIgnore(reqs, reqs.mapTo(mutableSetOf()) { it.name }, project)
- }
- }
- }
-
- private fun addStubPackagesToIgnore(stubPackages: List,
- stubPackagesNames: Set,
- project: Project) {
- ignoredPackages.removeIf { PyRequirementParser.fromLine(it)?.name in stubPackagesNames }
- ignoredPackages.addAll(stubPackages.map { it.presentableText })
-
- ProjectInspectionProfileManager.getInstance(project).fireProfileChanged()
- }
-
- private fun isIgnoredStubPackage(name: String, version: String, ignoredStubPackages: List): Boolean {
- val stubPackage = PyPackage(name, version)
- return ignoredStubPackages.any { stubPackage.matches(it) }
- }
- }
-}
diff --git a/python/src/com/jetbrains/python/codeInsight/typing/PyStubPackagesAdvertiserCache.kt b/python/src/com/jetbrains/python/codeInsight/typing/PyStubPackagesAdvertiserCache.kt
deleted file mode 100644
index 0b64dab72b29..000000000000
--- a/python/src/com/jetbrains/python/codeInsight/typing/PyStubPackagesAdvertiserCache.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright 2000-2019 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.codeInsight.typing
-
-import com.google.common.cache.Cache
-import com.google.common.cache.CacheBuilder
-import com.google.common.cache.CacheLoader
-import com.google.common.cache.LoadingCache
-import com.intellij.openapi.application.ApplicationManager
-import com.intellij.openapi.components.Service
-import com.intellij.openapi.projectRoots.Sdk
-import com.jetbrains.python.packaging.common.PythonPackageManagementListener
-import com.jetbrains.python.packaging.management.PythonPackageManager.Companion.PACKAGE_MANAGEMENT_TOPIC
-import java.time.Duration
-
-@Service
-class PyStubPackagesAdvertiserCache {
-
- private val cache: LoadingCache> = CacheBuilder.newBuilder()
- .maximumSize(3)
- .expireAfterAccess(Duration.ofMinutes(10))
- .build(
- CacheLoader.from { _ ->
- CacheBuilder.newBuilder()
- .maximumSize(50)
- .expireAfterAccess(Duration.ofMinutes(5))
- .build()
- }
- )
-
- init {
- val connection = ApplicationManager.getApplication().messageBus.connect()
- connection.subscribe(PACKAGE_MANAGEMENT_TOPIC, object : PythonPackageManagementListener {
- override fun packagesChanged(sdk: Sdk) {
- cache.invalidate(sdk)
- }
- })
- }
-
- fun forSdk(sdk: Sdk): Cache {
- return cache.get(sdk)
- }
-
- companion object {
- class StubPackagesForSource private constructor(val packages: Map>>) { // name to (version and extra args)
- companion object {
- val EMPTY = StubPackagesForSource(emptyMap())
-
- fun create(requirements: Map>>): StubPackagesForSource {
- return if (requirements.isEmpty()) EMPTY else StubPackagesForSource(requirements)
- }
- }
- }
- }
-}
diff --git a/python/src/com/jetbrains/python/codeInsight/typing/PyStubPackagesCompatibilityInspection.kt b/python/src/com/jetbrains/python/codeInsight/typing/PyStubPackagesCompatibilityInspection.kt
deleted file mode 100644
index d58eda17db50..000000000000
--- a/python/src/com/jetbrains/python/codeInsight/typing/PyStubPackagesCompatibilityInspection.kt
+++ /dev/null
@@ -1,118 +0,0 @@
-// 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.codeInsight.typing
-
-import com.intellij.codeInspection.LocalInspectionToolSession
-import com.intellij.codeInspection.LocalQuickFix
-import com.intellij.codeInspection.ProblemDescriptor
-import com.intellij.codeInspection.ProblemsHolder
-import com.intellij.codeInspection.options.OptPane
-import com.intellij.openapi.components.service
-import com.intellij.openapi.module.ModuleUtilCore
-import com.intellij.openapi.project.Project
-import com.intellij.openapi.projectRoots.Sdk
-import com.intellij.openapi.util.text.StringUtil
-import com.intellij.profile.codeInspection.ProjectInspectionProfileManager
-import com.intellij.psi.PsiElementVisitor
-import com.jetbrains.python.PyPsiBundle
-import com.jetbrains.python.inspections.PyInspection
-import com.jetbrains.python.inspections.PyInspectionVisitor
-import com.jetbrains.python.inspections.PyInterpreterInspection
-import com.jetbrains.python.packaging.PyPackage
-import com.jetbrains.python.packaging.PyPackageManager
-import com.jetbrains.python.packaging.requirement.PyRequirementRelation
-import com.jetbrains.python.psi.PyFile
-import com.jetbrains.python.psi.types.TypeEvalContext
-import com.jetbrains.python.sdk.PythonSdkUtil
-
-class PyStubPackagesCompatibilityInspection : PyInspection() {
-
- companion object {
- fun findIncompatibleRuntimeToStubPackages(sdk: Sdk,
- stubPkgsFilter: (PyPackage) -> Boolean): List> {
- val installedPackages = PyPackageManager.getInstance(sdk).packages ?: return emptyList()
- if (installedPackages.isEmpty()) return emptyList()
-
- val nameToPkg = mutableMapOf()
- installedPackages.forEach { nameToPkg[it.name] = it }
-
- return installedPackages
- .asSequence()
- .filter { it.name.isStubPackage() && stubPkgsFilter(it) }
- .mapNotNull { stubPkg ->
- (nameToPkg[stubPkg.name.stubPackageToPackage()])
- ?.let { it to stubPkg }
- }
- .filter {
- val runtimePkgName = it.first.name
- val requirement = it.second.requirements.firstOrNull { req -> req.equals(runtimePkgName) } ?: return@filter false
-
- requirement.match(listOf(it.first)) == null
- }
- .toList()
- }
- }
-
- @Suppress("MemberVisibilityCanBePrivate")
- var ignoredStubPackages: MutableList = mutableListOf()
-
- override fun getOptionsPane() =
- OptPane.pane(OptPane.stringList("ignoredStubPackages", PyPsiBundle.message("INSP.stub.packages.compatibility.ignored.packages.label")))
-
- override fun buildVisitor(holder: ProblemsHolder,
- isOnTheFly: Boolean,
- session: LocalInspectionToolSession): PsiElementVisitor {
- return Visitor(ignoredStubPackages, holder, PyInspectionVisitor.getContext(session))
- }
-
- private class Visitor(val ignoredStubPackages: MutableList,
- holder: ProblemsHolder,
- context: TypeEvalContext) : PyInspectionVisitor(holder, context) {
-
- override fun visitPyFile(node: PyFile) {
- val module = ModuleUtilCore.findModuleForFile(node) ?: return
- val sdk = PythonSdkUtil.findPythonSdk(module) ?: return
-
- val installedPackages = PyPackageManager.getInstance(sdk).packages ?: emptyList()
- if (installedPackages.isEmpty()) return
-
- val nameToPkg = mutableMapOf()
- installedPackages.forEach { nameToPkg[it.name] = it }
-
- val status = node.project.service()
-
- findIncompatibleRuntimeToStubPackages(
- sdk) { stubPkg ->
- stubPkg.name.let {
- !status.markedAsInstalling(it) && it !in ignoredStubPackages
- }
- }
- .forEach { (runtimePkg, stubPkg) ->
- val runtimePkgName = runtimePkg.name
- val requirement = stubPkg.requirements.firstOrNull { it.equals(runtimePkgName) } ?: return@forEach
-
- if (requirement.match(listOf(runtimePkg)) == null) {
- val stubPkgName = stubPkg.name
- val specsToString = StringUtil.join(requirement.versionSpecs, { it.presentableText }, ", ")
- val message = PyPsiBundle.message("INSP.stub.packages.compatibility.incompatible.packages.message",
- stubPkgName, PyRequirementRelation.EQ.presentableText, stubPkg.version,
- runtimePkgName, PyRequirementRelation.EQ.presentableText, runtimePkg.version,
- runtimePkgName, specsToString)
- registerProblem(node,
- message,
- PyInterpreterInspection.InterpreterSettingsQuickFix(module),
- createIgnoreStubPackageQuickFix(stubPkgName, ignoredStubPackages))
- }
- }
- }
-
- private fun createIgnoreStubPackageQuickFix(stubPkgName: String, ignoredStubPkgs: MutableList): LocalQuickFix {
- return object : LocalQuickFix {
- override fun getFamilyName() = PyPsiBundle.message("INSP.stub.packages.compatibility.ignore", stubPkgName)
-
- override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
- if (ignoredStubPkgs.add(stubPkgName)) ProjectInspectionProfileManager.getInstance(project).fireProfileChanged()
- }
- }
- }
- }
-}
diff --git a/python/src/com/jetbrains/python/codeInsight/typing/PyStubPackagesInstallingStatus.kt b/python/src/com/jetbrains/python/codeInsight/typing/PyStubPackagesInstallingStatus.kt
deleted file mode 100644
index 29c416e0f197..000000000000
--- a/python/src/com/jetbrains/python/codeInsight/typing/PyStubPackagesInstallingStatus.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-// Copyright 2000-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
-package com.jetbrains.python.codeInsight.typing
-
-import com.intellij.openapi.components.Service
-import java.util.concurrent.ConcurrentHashMap
-
-@Service(Service.Level.PROJECT)
-class PyStubPackagesInstallingStatus {
-
- private val installing = ConcurrentHashMap.newKeySet()
-
- fun markAsInstalling(stubPkgs: Collection): Boolean = installing.addAll(stubPkgs)
- fun unmarkAsInstalling(stubPkgs: Collection): Boolean = installing.removeAll(stubPkgs)
-
- fun markedAsInstalling(stubPkg: String): Boolean = installing.contains(stubPkg)
-}
\ No newline at end of file
diff --git a/python/src/com/jetbrains/python/codeInsight/typing/PyStubPackagesLoader.kt b/python/src/com/jetbrains/python/codeInsight/typing/PyStubPackagesLoader.kt
deleted file mode 100644
index f89bd58380a3..000000000000
--- a/python/src/com/jetbrains/python/codeInsight/typing/PyStubPackagesLoader.kt
+++ /dev/null
@@ -1,160 +0,0 @@
-// Copyright 2000-2020 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.codeInsight.typing
-
-import com.intellij.openapi.application.ApplicationManager
-import com.intellij.openapi.components.service
-import com.intellij.openapi.projectRoots.Sdk
-import com.intellij.util.CatchingConsumer
-import com.intellij.webcore.packaging.PackageManagementService
-import com.intellij.webcore.packaging.RepoPackage
-import com.jetbrains.python.packaging.PyPIPackageUtil
-import com.jetbrains.python.packaging.common.PythonPackage
-import java.util.*
-import java.util.function.BiConsumer
-
-
-fun loadStubPackagesForSources(sourcesToLoad: Set,
- sourceToPackage: Map,
- installedPackages: List,
- availablePackages: List,
- packageManagementService: PackageManagementService,
- sdk: Sdk) {
- val sourceToStubPackagesAvailableToInstall = sourceToStubPackagesAvailableToInstall(
- sourceToInstalledRuntimeAndStubPackages(sourcesToLoad, sourceToPackage, installedPackages),
- availablePackages
- )
-
- loadRequirementsAndExtraArgs(
- sourceToStubPackagesAvailableToInstall,
- packageManagementService,
- BiConsumer { source, stubPackagesForSource ->
- ApplicationManager.getApplication().service().forSdk(sdk).put(source, stubPackagesForSource)
- }
- )
-}
-
-private fun sourceToInstalledRuntimeAndStubPackages(sourcesToLoad: Set,
- sourceToPackage: Map,
- installedPackages: List): Map>> {
- val result = mutableMapOf>>()
-
- for (source in sourcesToLoad) {
- val pkgName = sourceToPackage[source] ?: continue
- installedRuntimeAndStubPackages(pkgName, installedPackages)?.let { result.put(source, listOf(it)) }
- }
-
- return result
-}
-
-private fun sourceToStubPackagesAvailableToInstall(sourceToInstalledRuntimeAndStubPkgs: Map>>,
- availablePackages: List): Map> {
- if (sourceToInstalledRuntimeAndStubPkgs.isEmpty()) return emptyMap()
-
- val stubPkgsAvailableToInstall = availablePackages.asSequence()
- .filter { it.name.isStubPackage() }
- .associateBy { it.name }
-
- return sourceToInstalledRuntimeAndStubPkgs.mapValues { (_, runtimeAndStubPkgs) ->
- runtimeAndStubPkgs
- .asSequence()
- .filter { it.second == null }
- .flatMap {
- setOfNotNull(
- stubPkgsAvailableToInstall["${it.first.name}$STUBS_SUFFIX"],
- stubPkgsAvailableToInstall["$TYPES_PREFIX${it.first.name}"],
- stubPkgsAvailableToInstall["${it.first.name}$TYPES_SUFFIX"],
- )
- }
- .toSet()
- }
-}
-
-private fun loadRequirementsAndExtraArgs(sourceToStubPackagesAvailableToInstall: Map>,
- packageManagementService: PackageManagementService,
- consumer: BiConsumer) {
- val commonState = CommonState(packageManagementService, consumer)
-
- for ((source, stubPackages) in sourceToStubPackagesAvailableToInstall) {
- if (stubPackages.isNotEmpty()) {
- val queue = LinkedList(stubPackages)
- loadRequirementAndExtraArgsForPackageAndThenContinueForSource(
- SourcePackageState(source, queue.poll(), queue, mutableMapOf(), commonState)
- )
- }
- }
-}
-
-private fun installedRuntimeAndStubPackages(pkgName: String, installedPackages: List): Pair? {
- var runtime: PythonPackage? = null
- var stub: PythonPackage? = null
- val stubPkgName = "$pkgName$STUBS_SUFFIX"
- val typesPkgName = "$TYPES_PREFIX$pkgName"
- val typesSuffixPkgName = "$pkgName$TYPES_SUFFIX"
-
- for (pkg in installedPackages) {
- val name = pkg.name
-
- if (name == pkgName) runtime = pkg
- if (name == stubPkgName || name == typesPkgName || name == typesSuffixPkgName) stub = pkg
- }
-
- return if (runtime == null) null else runtime to stub
-}
-
-private fun loadRequirementAndExtraArgsForPackageAndThenContinueForSource(state: SourcePackageState) {
- val commonState = state.commonState
- val name = state.pkg.name
-
- commonState.packageManagementService.fetchPackageVersions(
- name,
- object : CatchingConsumer, Exception> {
- override fun consume(e: Exception?) = continueLoadingRequirementsAndExtraArgsForSource(state)
-
- override fun consume(t: List?) {
- if (!t.isNullOrEmpty()) {
- val url = state.pkg.repoUrl
- val extraArgs =
- if (!url.isNullOrBlank() && !PyPIPackageUtil.isPyPIRepository(url)) {
- listOf("--extra-index-url", url)
- }
- else {
- emptyList()
- }
-
- state.result[name] = t.first() to extraArgs
- }
-
- continueLoadingRequirementsAndExtraArgsForSource(state)
- }
- }
- )
-}
-
-private fun continueLoadingRequirementsAndExtraArgsForSource(state: SourcePackageState) {
- val nextState = state.moveToNextPackage()
- if (nextState != null) {
- loadRequirementAndExtraArgsForPackageAndThenContinueForSource(nextState)
- }
- else {
- state.commonState.sourceResultConsumer.accept(
- state.source, PyStubPackagesAdvertiserCache.Companion.StubPackagesForSource.create(state.result)
- )
- }
-}
-
-private class SourcePackageState(
- val source: String,
- val pkg: RepoPackage,
- val queue: Queue,
- val result: MutableMap>>,
- val commonState: CommonState
-) {
- fun moveToNextPackage(): SourcePackageState? {
- return queue.poll()?.let { SourcePackageState(source, it, queue, result, commonState) }
- }
-}
-
-private class CommonState(
- val packageManagementService: PackageManagementService,
- val sourceResultConsumer: BiConsumer
-)
\ 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
index 54c1797de667..4d010a503f61 100644
--- a/python/src/com/jetbrains/python/packaging/common/packages.kt
+++ b/python/src/com/jetbrains/python/packaging/common/packages.kt
@@ -26,7 +26,10 @@ open class PythonPackage(name: String, val version: String, val isEditableMode:
private const val HASH_MULTIPLIER = 31
}
- val name: String = NormalizedPythonPackageName.from(name).name
+ @ApiStatus.Internal
+ val normalizedName: NormalizedPythonPackageName = NormalizedPythonPackageName.from(name)
+
+ val name: String = normalizedName.name
val presentableName: String = name
@ApiStatus.Internal
diff --git a/python/src/com/jetbrains/python/packaging/common/util.kt b/python/src/com/jetbrains/python/packaging/common/util.kt
index 46c936a0a72a..1203d4c063f8 100644
--- a/python/src/com/jetbrains/python/packaging/common/util.kt
+++ b/python/src/com/jetbrains/python/packaging/common/util.kt
@@ -8,7 +8,7 @@ import org.jetbrains.annotations.ApiStatus
@ApiStatus.Experimental
interface PythonPackageManagementListener {
- fun packagesChanged(sdk: Sdk)
+ fun packagesChanged(sdk: Sdk) {}
@ApiStatus.Internal
fun outdatedPackagesChanged(sdk: Sdk) {}
diff --git a/python/src/com/jetbrains/python/packaging/management/PythonRepositoryManager.kt b/python/src/com/jetbrains/python/packaging/management/PythonRepositoryManager.kt
index 0acdb522d1d9..5f96a1d3fb63 100644
--- a/python/src/com/jetbrains/python/packaging/management/PythonRepositoryManager.kt
+++ b/python/src/com/jetbrains/python/packaging/management/PythonRepositoryManager.kt
@@ -44,4 +44,12 @@ interface PythonRepositoryManager {
fun searchPackages(query: String): Map> {
return repositories.associateWith { searchPackages(query, it) }
}
+
+ @ApiStatus.Internal
+ suspend fun matchRequirement(requirement: PyRequirement): Boolean {
+ val versions = getVersions(requirement.name, null) ?: return false
+ return versions.any { version ->
+ requirement.versionSpecs.any { spec -> spec.matches(version) }
+ }
+ }
}
diff --git a/python/testData/requirements/inspections/PyStubPackagesAdvertiserTest/numpy_example.py b/python/testData/requirements/inspections/PyStubPackagesAdvertiserTest/numpy_example.py
new file mode 100644
index 000000000000..a22b22b1757d
--- /dev/null
+++ b/python/testData/requirements/inspections/PyStubPackagesAdvertiserTest/numpy_example.py
@@ -0,0 +1 @@
+import numpy as np
diff --git a/python/testData/requirements/inspections/PyStubPackagesAdvertiserTest/pandas_example.py b/python/testData/requirements/inspections/PyStubPackagesAdvertiserTest/pandas_example.py
new file mode 100644
index 000000000000..bd2deffb9cc7
--- /dev/null
+++ b/python/testData/requirements/inspections/PyStubPackagesAdvertiserTest/pandas_example.py
@@ -0,0 +1,4 @@
+import pandas as pd
+import pandas as pd
+from pandas import *
+from pandas import *
diff --git a/python/testData/requirements/inspections/PyStubPackagesCompatibilityInspectionTest/numpy_example.py b/python/testData/requirements/inspections/PyStubPackagesCompatibilityInspectionTest/numpy_example.py
new file mode 100644
index 000000000000..a22b22b1757d
--- /dev/null
+++ b/python/testData/requirements/inspections/PyStubPackagesCompatibilityInspectionTest/numpy_example.py
@@ -0,0 +1 @@
+import numpy as np
diff --git a/python/testData/requirements/inspections/PyStubPackagesCompatibilityInspectionTest/pandas_example.py b/python/testData/requirements/inspections/PyStubPackagesCompatibilityInspectionTest/pandas_example.py
new file mode 100644
index 000000000000..bd2deffb9cc7
--- /dev/null
+++ b/python/testData/requirements/inspections/PyStubPackagesCompatibilityInspectionTest/pandas_example.py
@@ -0,0 +1,4 @@
+import pandas as pd
+import pandas as pd
+from pandas import *
+from pandas import *
diff --git a/python/testSrc/com/jetbrains/python/packaging/management/TestPackageManager.kt b/python/testSrc/com/jetbrains/python/packaging/management/TestPackageManager.kt
index cef1b0df94c2..f1716b3aef14 100644
--- a/python/testSrc/com/jetbrains/python/packaging/management/TestPackageManager.kt
+++ b/python/testSrc/com/jetbrains/python/packaging/management/TestPackageManager.kt
@@ -18,9 +18,13 @@ class TestPythonPackageManager(project: Project, sdk: Sdk) : PythonPackageManage
override var dependencies: List = emptyList()
private var packageNames: List = emptyList()
private var packageDetails: PythonPackageDetails? = null
+ private var packageVersions: Map> = emptyMap()
- override val repositoryManager: PythonRepositoryManager
- get() = TestPythonRepositoryManager(project).withPackageNames(packageNames).withPackageDetails(packageDetails)
+ override val repositoryManager: TestPythonRepositoryManager
+ get() = TestPythonRepositoryManager(project)
+ .withPackageNames(packageNames)
+ .withPackageDetails(packageDetails)
+ .withRepoPackagesVersions(packageVersions)
override fun getDependencyManager(): PythonDependenciesManager? {
val data = sdk.getUserData(REQUIREMENTS_PROVIDER_KEY) ?: return null
@@ -77,6 +81,12 @@ class TestPythonPackageManager(project: Project, sdk: Sdk) : PythonPackageManage
return installedPackages.find { it.name == name }
}
+ fun withRepoPackagesVersions(packageVersions: Map>): TestPythonPackageManager {
+ this.packageVersions = packageVersions
+ return this
+ }
+
+
fun withPackageNames(packageNames: List): TestPythonPackageManager {
this.packageNames = packageNames
return this
diff --git a/python/testSrc/com/jetbrains/python/packaging/management/TestPackageManagerProvider.kt b/python/testSrc/com/jetbrains/python/packaging/management/TestPackageManagerProvider.kt
index 97ccfc446a7e..b477d25463bc 100644
--- a/python/testSrc/com/jetbrains/python/packaging/management/TestPackageManagerProvider.kt
+++ b/python/testSrc/com/jetbrains/python/packaging/management/TestPackageManagerProvider.kt
@@ -12,6 +12,7 @@ class TestPackageManagerProvider : PythonPackageManagerProvider {
private var packageNames: List = emptyList()
private var packageDetails: PythonPackageDetails? = null
private var packageInstalled: List = emptyList()
+ private var packageVersions = mapOf>()
fun withPackageNames(packageNames: List): TestPackageManagerProvider {
this.packageNames = packageNames
@@ -23,7 +24,22 @@ class TestPackageManagerProvider : PythonPackageManagerProvider {
return this
}
+ fun withPackageInstalled(vararg packages: PythonPackage): TestPackageManagerProvider {
+ this.packageInstalled = packages.toList()
+ return this
+ }
+
+ fun withRepoPackagesVersions(versions: Map>): TestPackageManagerProvider {
+ this.packageVersions = versions
+ return this
+ }
+
+
override fun createPackageManagerForSdk(project: Project, sdk: Sdk): PythonPackageManager {
- return TestPythonPackageManager(project, sdk).withPackageNames(packageNames).withPackageDetails(packageDetails).withPackageInstalled(packageInstalled)
+ return TestPythonPackageManager(project, sdk)
+ .withPackageNames(packageNames)
+ .withPackageDetails(packageDetails)
+ .withPackageInstalled(packageInstalled)
+ .withRepoPackagesVersions(packageVersions)
}
}
\ No newline at end of file
diff --git a/python/testSrc/com/jetbrains/python/packaging/management/TestPythonRepositoryManager.kt b/python/testSrc/com/jetbrains/python/packaging/management/TestPythonRepositoryManager.kt
index 4624a805b063..c4476b21c55b 100644
--- a/python/testSrc/com/jetbrains/python/packaging/management/TestPythonRepositoryManager.kt
+++ b/python/testSrc/com/jetbrains/python/packaging/management/TestPythonRepositoryManager.kt
@@ -12,13 +12,14 @@ import com.jetbrains.python.packaging.repository.PyPackageRepository
import org.jetbrains.annotations.TestOnly
@TestOnly
-internal class TestPythonRepositoryManager(
+class TestPythonRepositoryManager(
override val project: Project,
) : PythonRepositoryManager {
private var packageNames: Set = emptySet()
private var packageDetails: PythonPackageDetails? = null
+ private var packageVersions = mapOf>()
override suspend fun findPackageSpecification(requirement: PyRequirement, repository: PyPackageRepository?): PythonRepositoryPackageSpecification {
return PythonRepositoryPackageSpecification(repository ?: PyPIPackageRepository, requirement)
}
@@ -35,6 +36,11 @@ internal class TestPythonRepositoryManager(
}
+ fun withRepoPackagesVersions(versions: Map>): TestPythonRepositoryManager {
+ this.packageVersions = versions
+ return this
+ }
+
override val repositories: List
get() = listOf(TestPackageRepository(packageNames))
@@ -54,7 +60,7 @@ internal class TestPythonRepositoryManager(
}
override suspend fun getVersions(packageName: String, repository: PyPackageRepository?): List {
- return packageDetails?.availableVersions?.toList().orEmpty()
+ return packageDetails?.availableVersions?.toList()?.ifEmpty { null } ?: packageVersions[packageName].orEmpty()
}
override suspend fun getLatestVersion(packageName: String, repository: PyPackageRepository?): PyPackageVersion {
diff --git a/python/testSrc/com/jetbrains/python/requirements/PyStubPackagesAdvertiserTest.kt b/python/testSrc/com/jetbrains/python/requirements/PyStubPackagesAdvertiserTest.kt
new file mode 100644
index 000000000000..4ab513f30bf1
--- /dev/null
+++ b/python/testSrc/com/jetbrains/python/requirements/PyStubPackagesAdvertiserTest.kt
@@ -0,0 +1,90 @@
+// 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.requirements
+
+import com.intellij.codeInspection.ex.InspectionProfileImpl
+import com.intellij.lang.annotation.HighlightSeverity
+import com.intellij.testFramework.TestDataPath
+import com.jetbrains.python.PyBundle
+import com.jetbrains.python.codeInsight.stubs.PyStubPackagesAdvertiser
+import com.jetbrains.python.packaging.common.PythonPackage
+import com.jetbrains.python.packaging.management.TestPackageManagerProvider
+import com.jetbrains.python.sdk.pythonSdk
+
+@TestDataPath("\$CONTENT_ROOT/../testData/requirements/inspections")
+class PyStubPackagesAdvertiserTest : PythonDependencyTestCase() {
+ fun testAdvertiseImportedPackageIfRequiredStubExists() {
+ val provider = TestPackageManagerProvider()
+ .withPackageInstalled(PythonPackage("pandas", "3.0.0", false))
+ .withRepoPackagesVersions(mapOf("pandas" to listOf("3.0.0"), "pandas-stubs" to listOf("3.0.0")))
+ initTestPackageManager(provider)
+
+
+ doMultiFileTest("pandas_example.py")
+ val highlightInfos = myFixture.doHighlighting()
+ assertSize(1, highlightInfos)
+ assertEquals(HighlightSeverity.WARNING, highlightInfos[0].severity)
+ assertEquals(PyBundle.message("code.insight.type.hints.are.not.installed", "pandas-stubs"), highlightInfos[0].description)
+ }
+
+ fun testAdvertiseImportedPackageIfCompatibleStubExists() {
+ val provider = TestPackageManagerProvider()
+ .withPackageInstalled(PythonPackage("pandas", "3.0.0", false))
+ .withRepoPackagesVersions(mapOf("pandas" to listOf("3.0.0"), "pandas-stubs" to listOf("3.0.0.1")))
+ initTestPackageManager(provider)
+
+
+ doMultiFileTest("pandas_example.py")
+ val highlightInfos = myFixture.doHighlighting()
+ assertSize(1, highlightInfos)
+ assertEquals(HighlightSeverity.WARNING, highlightInfos[0].severity)
+ assertEquals(PyBundle.message("code.insight.type.hints.are.not.installed", "pandas-stubs"), highlightInfos[0].description)
+ }
+
+ fun testNotAdvertiseNotImportedPackageIfRequiredStubExists() {
+ val provider = TestPackageManagerProvider()
+ .withPackageInstalled(PythonPackage("pandas", "3.0.0", false))
+ .withRepoPackagesVersions(mapOf("pandas" to listOf("3.0.0"), "pandas-stubs" to listOf("3.0.0")))
+ initTestPackageManager(provider)
+
+
+ doMultiFileTest("numpy_example.py")
+ val highlightInfos = myFixture.doHighlighting()
+ assertEmpty(highlightInfos)
+ }
+
+ fun testNotAdvertiseImportedPackageIfRequiredStubDoesNotExists() {
+ val provider = TestPackageManagerProvider()
+ .withPackageInstalled(PythonPackage("pandas", "3.0.0", false))
+ .withRepoPackagesVersions(mapOf("pandas" to listOf("3.0.0"), "pandas-stubs" to listOf("2.9.0")))
+ initTestPackageManager(provider)
+
+
+ doMultiFileTest("pandas_example.py")
+ val highlightInfos = myFixture.doHighlighting()
+ assertEmpty(highlightInfos)
+ }
+
+
+ private fun doMultiFileTest(filename: String) {
+ myFixture.copyDirectoryToProject(this::class.java.simpleName, "")
+ myFixture.configureFromTempProjectFile(filename)
+ getPythonSdk(myFixture.file)!!
+ myFixture.enableInspections(PyStubPackagesAdvertiser::class.java)
+ }
+
+ override fun setUp() {
+ super.setUp()
+ InspectionProfileImpl.INIT_INSPECTIONS = true
+ myFixture.project.pythonSdk = projectDescriptor.sdk
+ }
+
+ override fun tearDown() {
+ InspectionProfileImpl.INIT_INSPECTIONS = false
+ super.tearDown()
+ }
+
+
+ override fun getBasePath(): String {
+ return super.getBasePath() + "inspections/"
+ }
+}
\ No newline at end of file
diff --git a/python/testSrc/com/jetbrains/python/requirements/PyStubPackagesCompatibilityInspectionTest.kt b/python/testSrc/com/jetbrains/python/requirements/PyStubPackagesCompatibilityInspectionTest.kt
new file mode 100644
index 000000000000..265fc7dfb42d
--- /dev/null
+++ b/python/testSrc/com/jetbrains/python/requirements/PyStubPackagesCompatibilityInspectionTest.kt
@@ -0,0 +1,113 @@
+// 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.requirements
+
+import com.intellij.codeInspection.ex.InspectionProfileImpl
+import com.intellij.lang.annotation.HighlightSeverity
+import com.intellij.testFramework.TestDataPath
+import com.jetbrains.python.PyPsiBundle
+import com.jetbrains.python.codeInsight.stubs.PyStubPackagesCompatibilityInspection
+import com.jetbrains.python.packaging.common.PythonPackage
+import com.jetbrains.python.packaging.management.TestPackageManagerProvider
+import com.jetbrains.python.sdk.pythonSdk
+
+@TestDataPath("\$CONTENT_ROOT/../testData/requirements/inspections")
+class PyStubPackagesCompatibilityInspectionTest : PythonDependencyTestCase() {
+ fun testAdvertiseImportedPackageIfOldStubAndCompatibleExists() {
+ val provider = TestPackageManagerProvider()
+ .withPackageInstalled(PythonPackage("pandas", "3.0.0", false),
+ PythonPackage("pandas-stubs", "2.9.0", false)
+ )
+ .withRepoPackagesVersions(mapOf("pandas" to listOf("3.0.0"), "pandas-stubs" to listOf("3.0.0", "2.9.0")))
+ initTestPackageManager(provider)
+
+
+ doMultiFileTest("pandas_example.py")
+ val highlightInfos = myFixture.doHighlighting()
+ assertSize(1, highlightInfos)
+ assertEquals(HighlightSeverity.WARNING, highlightInfos[0].severity)
+ assertEquals(PyPsiBundle.message("INSP.stub.packages.compatibility.incompatible.packages.message", "pandas-stubs"), highlightInfos[0].description)
+ }
+
+ fun testAdvertiseImportedPackageIfNewStubAndCompatibleExists() {
+ val provider = TestPackageManagerProvider()
+ .withPackageInstalled(PythonPackage("pandas", "2.9.0", false),
+ PythonPackage("pandas-stubs", "3.0.0", false)
+ )
+ .withRepoPackagesVersions(mapOf("pandas" to listOf("2.9.0", "3.0.0"), "pandas-stubs" to listOf("3.0.0", "2.9.0")))
+ initTestPackageManager(provider)
+
+
+ doMultiFileTest("pandas_example.py")
+ val highlightInfos = myFixture.doHighlighting()
+ assertSize(1, highlightInfos)
+ assertEquals(HighlightSeverity.WARNING, highlightInfos[0].severity)
+ assertEquals(PyPsiBundle.message("INSP.stub.packages.compatibility.incompatible.packages.message", "pandas-stubs"), highlightInfos[0].description)
+ }
+
+
+ fun testNotAdvertiseImportedPackageIfNotCompatibleAndCompatibleDoesNotExists() {
+ val provider = TestPackageManagerProvider()
+ .withPackageInstalled(PythonPackage("pandas", "3.0.0", false),
+ PythonPackage("pandas-stubs", "2.9.0", false)
+ )
+ .withRepoPackagesVersions(mapOf("pandas" to listOf("3.0.0"), "pandas-stubs" to listOf("2.9.0")))
+ initTestPackageManager(provider)
+
+
+ doMultiFileTest("pandas_example.py")
+ val highlightInfos = myFixture.doHighlighting()
+ assertEmpty(highlightInfos)
+ }
+
+ fun testNotAdvertiseNotImportedPackageIfNotCompatibleAndCompatibleDoesNotExists() {
+ val provider = TestPackageManagerProvider()
+ .withPackageInstalled(PythonPackage("pandas", "3.0.0", false),
+ PythonPackage("pandas-stubs", "2.9.0", false)
+ )
+ .withRepoPackagesVersions(mapOf("pandas" to listOf("3.0.0"), "pandas-stubs" to listOf("2.9.0", "3.0.0")))
+ initTestPackageManager(provider)
+
+
+ doMultiFileTest("numpy_example.py")
+ val highlightInfos = myFixture.doHighlighting()
+ assertEmpty(highlightInfos)
+ }
+
+ fun testNotAdvertiseImportedPackageIfCompatible() {
+ val provider = TestPackageManagerProvider()
+ .withPackageInstalled(PythonPackage("pandas", "3.0.0", false),
+ PythonPackage("pandas-stubs", "3.0.0", false)
+ )
+ .withRepoPackagesVersions(mapOf("pandas" to listOf("3.0.0"), "pandas-stubs" to listOf("3.0.0.1", "3.0.0")))
+ initTestPackageManager(provider)
+
+
+ doMultiFileTest("numpy_example.py")
+ val highlightInfos = myFixture.doHighlighting()
+ assertEmpty(highlightInfos)
+ }
+
+
+ private fun doMultiFileTest(filename: String) {
+ myFixture.copyDirectoryToProject(this::class.java.simpleName, "")
+ myFixture.configureFromTempProjectFile(filename)
+ getPythonSdk(myFixture.file)!!
+ myFixture.enableInspections(PyStubPackagesCompatibilityInspection::class.java)
+ }
+
+ override fun setUp() {
+ super.setUp()
+ InspectionProfileImpl.INIT_INSPECTIONS = true
+ myFixture.project.pythonSdk = projectDescriptor.sdk
+ }
+
+ override fun tearDown() {
+ InspectionProfileImpl.INIT_INSPECTIONS = false
+ super.tearDown()
+ }
+
+
+ override fun getBasePath(): String {
+ return super.getBasePath() + "inspections/"
+ }
+}
\ No newline at end of file
diff --git a/python/testSrc/com/jetbrains/python/requirements/PythonDependencyTestCase.kt b/python/testSrc/com/jetbrains/python/requirements/PythonDependencyTestCase.kt
index 24c81acd4fdc..ffc3fda1a091 100644
--- a/python/testSrc/com/jetbrains/python/requirements/PythonDependencyTestCase.kt
+++ b/python/testSrc/com/jetbrains/python/requirements/PythonDependencyTestCase.kt
@@ -22,16 +22,20 @@ import com.jetbrains.python.sdk.pythonSdk
abstract class PythonDependencyTestCase : BasePlatformTestCase() {
+ protected fun initTestPackageManager(provider: TestPackageManagerProvider) {
+ ExtensionTestUtil.maskExtensions(PythonPackageManagerProvider.EP_NAME, listOf(provider), testRootDisposable)
+ }
+
protected fun mockPackageNames(packageNames: List) {
val packageManagerProvider = TestPackageManagerProvider().withPackageNames(packageNames)
- ExtensionTestUtil.maskExtensions(PythonPackageManagerProvider.EP_NAME, listOf(packageManagerProvider), testRootDisposable)
+ initTestPackageManager(packageManagerProvider)
}
protected fun mockPackageDetails(packageName: String, availableVersions: List) {
val packageManagerProvider = TestPackageManagerProvider()
.withPackageNames(listOf(packageName))
.withPackageDetails(PythonSimplePackageDetails(packageName, availableVersions, TestPackageRepository(emptySet())))
- ExtensionTestUtil.maskExtensions(PythonPackageManagerProvider.EP_NAME, listOf(packageManagerProvider), testRootDisposable)
+ initTestPackageManager(packageManagerProvider)
}
protected fun checkCompletionResults(vararg expected: String) {
@@ -41,7 +45,7 @@ abstract class PythonDependencyTestCase : BasePlatformTestCase() {
protected fun checkCompletionResultsOrdered(vararg expected: String) {
assertNotEmpty(myFixture.lookupElementStrings)
- UsefulTestCase.assertContainsOrdered(myFixture.lookupElementStrings!!, *expected)
+ assertContainsOrdered(myFixture.lookupElementStrings!!, *expected)
}
protected fun completeInTomlFile() {