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 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() {