PY-90285 inspections: name enclosing declaration in suppress actions

(cherry picked from commit 711a46ac788e7a30cc6e7316d85d8f3c89fbcdd9)

GitOrigin-RevId: dc4f25279992a308f48a71335a79bed49e1b4d93
This commit is contained in:
Morgan Bartholomew
2026-07-08 03:11:00 +00:00
committed by intellij-monorepo-bot
parent 35e325f12e
commit 9c2ee70e4f
5 changed files with 96 additions and 52 deletions
@@ -1394,10 +1394,9 @@ INSP.variance.checker.superfluous=Superfluous variance since the given constrain
# PyInspectionsSuppressor
INSP.python.suppressor.suppress.for.function=Suppress for a function
INSP.python.suppressor.suppress.for.class=Suppress for a class
INSP.python.suppressor.suppress.for.statement=Suppress for a statement
INSP.python.suppressor.suppress.code.for.function=Suppress ''{0}'' for a function
INSP.python.suppressor.suppress.code.for.class=Suppress ''{0}'' for a class
INSP.python.suppressor.suppress.code.for.statement=Suppress ''{0}'' for a statement
INSP.python.suppressor.suppress.for.statement=Suppress for this statement
INSP.python.suppressor.suppress.for.function.named=Suppress for function ''{0}''
INSP.python.suppressor.suppress.for.class.named=Suppress for class ''{0}''
find.usages.unnamed=<unnamed>
find.usages.keyword.argument=keyword argument
@@ -11,40 +11,75 @@ import com.jetbrains.python.psi.PyClass;
import com.jetbrains.python.psi.PyFunction;
import com.jetbrains.python.psi.PyStatement;
import com.jetbrains.python.psi.PyStatementList;
import org.jetbrains.annotations.Nls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
public final class PyInspectionsSuppressor implements InspectionSuppressor {
private static final String PY_INCORRECT_DOCSTRING_INSPECTION_ID = new PyIncorrectDocstringInspection().getID();
private static final String PY_MISSING_OR_EMPTY_DOCSTRING_INSPECTION_ID = new PyMissingOrEmptyDocstringInspection().getID();
@Override
public SuppressQuickFix @NotNull [] getSuppressActions(@Nullable PsiElement element, @NotNull String toolId) {
// PyTypeChecker owns its suppression UI entirely (PyTypeCheckerSuppressableProblemGroup); offer no blanket
// actions for it, so the only suppressions shown are its granular per-code ones.
if (PySuppressionUtil.INSTANCE.isCustomManaged(toolId)) {
return SuppressQuickFix.EMPTY_ARRAY;
}
// Insert the industry-standard kebab-case alias (e.g. `method-overriding`) when the inspection has one,
// so that is the form users adopt; the legacy `PyMethodOverriding` id keeps working for recognition.
final String suppressId = suppressId(toolId);
if (PY_INCORRECT_DOCSTRING_INSPECTION_ID.equals(toolId) || PY_MISSING_OR_EMPTY_DOCSTRING_INSPECTION_ID.equals(toolId)) {
return new SuppressQuickFix[]{
new PySuppressInspectionFix(suppressId, PyPsiBundle.message("INSP.python.suppressor.suppress.for.function"), PyFunction.class),
new PySuppressInspectionFix(suppressId, PyPsiBundle.message("INSP.python.suppressor.suppress.for.class"), PyClass.class)
};
}
else {
return new SuppressQuickFix[]{
new PySuppressInspectionFix(suppressId, PyPsiBundle.message("INSP.python.suppressor.suppress.for.statement"), PyStatement.class) {
@Override
public PsiElement getContainer(PsiElement context) {
if (PsiTreeUtil.getParentOfType(context, PyStatementList.class, false, ScopeOwner.class) != null ||
PsiTreeUtil.getParentOfType(context, PyFunction.class, PyClass.class) == null) {
return super.getContainer(context);
}
return null;
// Docstring inspections only attach to declarations, so they offer no per-statement suppression.
final boolean includeStatement = !PY_INCORRECT_DOCSTRING_INSPECTION_ID.equals(toolId) &&
!PY_MISSING_OR_EMPTY_DOCSTRING_INSPECTION_ID.equals(toolId);
return createSuppressActions(suppressId, element, includeStatement);
}
/**
* Builds the "Suppress for this statement / for function 'f' / for class 'C'" quick fixes that insert a
* {@code # noinspection <suppressId>} comment. Shared with {@link PyTypeCheckerSuppressableProblemGroup},
* which passes one of its granular codes as {@code suppressId} but reuses the same declaration-naming wording.
*/
public static SuppressQuickFix @NotNull [] createSuppressActions(@NotNull String suppressId,
@Nullable PsiElement element,
boolean includeStatement) {
List<SuppressQuickFix> fixes = new ArrayList<>(3);
if (includeStatement) {
fixes.add(new PySuppressInspectionFix(suppressId, PyPsiBundle.message("INSP.python.suppressor.suppress.for.statement"), PyStatement.class) {
@Override
public PsiElement getContainer(PsiElement context) {
if (PsiTreeUtil.getParentOfType(context, PyStatementList.class, false, ScopeOwner.class) != null ||
PsiTreeUtil.getParentOfType(context, PyFunction.class, PyClass.class) == null) {
return super.getContainer(context);
}
},
new PySuppressInspectionFix(suppressId, PyPsiBundle.message("INSP.python.suppressor.suppress.for.function"), PyFunction.class),
new PySuppressInspectionFix(suppressId, PyPsiBundle.message("INSP.python.suppressor.suppress.for.class"), PyClass.class)
};
return null;
}
});
}
fixes.add(new PySuppressInspectionFix(suppressId, functionText(element), PyFunction.class));
fixes.add(new PySuppressInspectionFix(suppressId, classText(element), PyClass.class));
return fixes.toArray(SuppressQuickFix.EMPTY_ARRAY);
}
// Name the enclosing function/class in the action text (like Kotlin), falling back to the generic wording
// when there is no enclosing declaration (e.g. when the list is requested from the inspection tool window).
private static @Nls @NotNull String functionText(@Nullable PsiElement element) {
PyFunction function = element == null ? null : PsiTreeUtil.getParentOfType(element, PyFunction.class);
String name = function == null ? null : function.getName();
return name != null
? PyPsiBundle.message("INSP.python.suppressor.suppress.for.function.named", name)
: PyPsiBundle.message("INSP.python.suppressor.suppress.for.function");
}
private static @Nls @NotNull String classText(@Nullable PsiElement element) {
PyClass pyClass = element == null ? null : PsiTreeUtil.getParentOfType(element, PyClass.class);
String name = pyClass == null ? null : pyClass.getName();
return name != null
? PyPsiBundle.message("INSP.python.suppressor.suppress.for.class.named", name)
: PyPsiBundle.message("INSP.python.suppressor.suppress.for.class");
}
@Override
@@ -22,6 +22,13 @@ import java.util.regex.Pattern
internal object PySuppressionUtil {
private val SUPPRESS_PATTERN: Pattern = Pattern.compile(SuppressionUtil.COMMON_SUPPRESS_REGEXP)
/**
* `PyTypeChecker` is excluded from the blanket alias mechanism: it fully manages its own granular suppression
* codes ([PyTypeCheckerSuppressionCode]), so it gets no auto-derived `type-checker` alias and keeps the legacy
* `PyTypeChecker` id as its catch-all.
*/
private const val PY_TYPE_CHECKER_INSPECTION_ID = "PyTypeChecker"
/**
* Returns `true` if [element] is covered by a `# noinspection <suppressId>` comment placed above its
* enclosing statement, function, or class.
@@ -31,15 +38,23 @@ internal object PySuppressionUtil {
isSuppressedForParent(element, PyFunction::class.java, suppressId) ||
isSuppressedForParent(element, PyClass::class.java, suppressId)
/**
* `true` for inspections that fully manage their own suppression UI (currently only `PyTypeChecker`, via
* [PyTypeCheckerSuppressableProblemGroup]). [PyInspectionsSuppressor] offers no blanket suppress actions for
* them, leaving the per-problem `SuppressableProblemGroup` as the sole source.
*/
fun isCustomManaged(toolId: String): Boolean = toolId == PY_TYPE_CHECKER_INSPECTION_ID
/**
* Maps a legacy inspection id to its industry-standard kebab-case suppression alias by dropping the `Py`
* product prefix and converting CamelCase to `kebab-case` (e.g. `PyMethodOverriding` -> `method-overriding`,
* `PyUnresolvedReferences` -> `unresolved-references`).
*
* Returns `null` for ids that are not `Py`-prefixed inspection ids (e.g. platform inspections that also run
* on Python files), which keep their original id only.
* on Python files), which keep their original id only, and for [PY_TYPE_CHECKER_INSPECTION_ID].
*/
fun toSuppressionCode(toolId: String): String? {
if (toolId == PY_TYPE_CHECKER_INSPECTION_ID) return null
if (toolId.length < 3 || !toolId.startsWith("Py") || !toolId[2].isUpperCase()) return null
return toolId.substring(2)
.replace(Regex("([A-Z]+)([A-Z][a-z])"), "$1-$2")
@@ -3,22 +3,14 @@ package com.jetbrains.python.inspections
import com.intellij.codeInspection.SuppressIntentionAction
import com.intellij.codeInspection.SuppressIntentionActionFromFix
import com.intellij.codeInspection.SuppressQuickFix
import com.intellij.codeInspection.SuppressableProblemGroup
import com.intellij.psi.PsiElement
import com.intellij.psi.util.PsiTreeUtil
import com.jetbrains.python.PyPsiBundle
import com.jetbrains.python.codeInsight.controlflow.ScopeOwner
import com.jetbrains.python.inspections.quickfix.PySuppressInspectionFix
import com.jetbrains.python.psi.PyClass
import com.jetbrains.python.psi.PyFunction
import com.jetbrains.python.psi.PyStatement
import com.jetbrains.python.psi.PyStatementList
/**
* Provides the Alt-Enter "Suppress '<code>' for a …" actions for a single [PyTypeCheckerInspection] problem
* tagged with [code]. Each action inserts a `# noinspection <code>` comment, which
* [PyTypeCheckerSuppressionUtil] then recognizes.
* Provides the Alt-Enter "Suppress for this statement / for function 'f' / for class 'C'" actions for a single
* [PyTypeCheckerInspection] problem tagged with [code]. The actions reuse
* [PyInspectionsSuppressor.createSuppressActions] for identical, declaration-naming wording, but insert a
* `# noinspection <code.id>` comment (e.g. `bad-return`), which [PySuppressionUtil] then recognizes.
*
* [getProblemName] returns `null` on purpose: a non-null name would re-key the highlight's
* `HighlightDisplayKey` to the code (for which no inspection tool is registered), and the highlight would be
@@ -28,20 +20,7 @@ internal class PyTypeCheckerSuppressableProblemGroup(private val code: PyTypeChe
override fun getProblemName(): String? = null
override fun getSuppressActions(element: PsiElement?): Array<SuppressIntentionAction> {
val id = code.id
val fixes = arrayOf<SuppressQuickFix>(
object : PySuppressInspectionFix(id, PyPsiBundle.message("INSP.python.suppressor.suppress.code.for.statement", id), PyStatement::class.java) {
override fun getContainer(context: PsiElement?): PsiElement? {
if (PsiTreeUtil.getParentOfType(context, PyStatementList::class.java, false, ScopeOwner::class.java) != null ||
PsiTreeUtil.getParentOfType(context, PyFunction::class.java, PyClass::class.java) == null) {
return super.getContainer(context)
}
return null
}
},
PySuppressInspectionFix(id, PyPsiBundle.message("INSP.python.suppressor.suppress.code.for.function", id), PyFunction::class.java),
PySuppressInspectionFix(id, PyPsiBundle.message("INSP.python.suppressor.suppress.code.for.class", id), PyClass::class.java),
)
val fixes = PyInspectionsSuppressor.createSuppressActions(code.id, element, true)
return SuppressIntentionActionFromFix.convertBatchToSuppressIntentionActions(fixes)
}
}
@@ -48,7 +48,7 @@ public class PySuppressInspectionsTest extends PyTestCase {
public void testSuppressForStatement() {
myFixture.configureByFile("inspections/suppress/suppressForStatement.py");
myFixture.enableInspections(PyUnresolvedReferencesInspection.class);
final List<IntentionAction> intentions = myFixture.filterAvailableIntentions("Suppress for a statement");
final List<IntentionAction> intentions = myFixture.filterAvailableIntentions("Suppress for this statement");
// Rename reference, Ignore unresolved references, Ignore all unresolved attributes of
assertEquals(3, intentions.size());
final IntentionAction suppressAction = intentions.get(0);
@@ -63,4 +63,20 @@ public class PySuppressInspectionsTest extends PyTestCase {
myFixture.enableInspections(PyUnresolvedReferencesInspection.class);
myFixture.checkHighlighting(true, false, true);
}
// The suppress actions name the enclosing function/class (like Kotlin), instead of a bare "a function"/"a class".
@TestFor(issues="PY-90285")
public void testSuppressActionNamesFunction() {
myFixture.configureByText("a.py", "def f():\n print(x<caret>xx)");
myFixture.enableInspections(PyUnresolvedReferencesInspection.class);
assertFalse(myFixture.filterAvailableIntentions("Suppress for function 'f'").isEmpty());
}
@TestFor(issues="PY-90285")
public void testSuppressActionNamesClassAndMethod() {
myFixture.configureByText("a.py", "class C:\n def m(self):\n print(x<caret>xx)");
myFixture.enableInspections(PyUnresolvedReferencesInspection.class);
assertFalse(myFixture.filterAvailableIntentions("Suppress for class 'C'").isEmpty());
assertFalse(myFixture.filterAvailableIntentions("Suppress for function 'm'").isEmpty());
}
}