PY-84322 Code compatibility inspection doesn't warn about absent parentheses in except clauses in Python <3.14

- add incompatibility inspections, quickfix, tests
- improve quickfix for adding parentheses
- add AST tests for PY-84077

GitOrigin-RevId: 2d09a06d4d0b5ae106f556808900573c50f41800
This commit is contained in:
Marcus Mews
2025-09-25 08:31:43 +00:00
committed by intellij-monorepo-bot
parent c1985c7eab
commit 9c2d77a961
16 changed files with 253 additions and 27 deletions
@@ -803,6 +803,8 @@ INSP.compatibility.new.union.syntax.not.available.in.earlier.version=allow writi
INSP.compatibility.feature.support.match.statements=support match statements
INSP.compatibility.feature.support.parenthesized.context.expressions=support parenthesized context expressions
INSP.compatibility.feature.support.starred.except.part=support except* part
INSP.compatibility.except.clause.different.semantics=Different semantics of comma-separated elements in Python versions {0}.
INSP.compatibility.except.clause.missing.parens=support missing parentheses in except clauses
# PyUnnecessaryBackslashInspection
INSP.NAME.unnecessary.backslash=Unnecessary backslash
@@ -29,6 +29,7 @@ import com.intellij.openapi.roots.ProjectRootManager;
import com.intellij.openapi.util.JDOMExternalizableStringList;
import com.intellij.openapi.util.NlsSafe;
import com.intellij.openapi.util.TextRange;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.profile.codeInspection.InspectionProjectProfileManager;
import com.intellij.psi.*;
@@ -39,6 +40,7 @@ import com.intellij.util.containers.ContainerUtil;
import com.jetbrains.python.PyNames;
import com.jetbrains.python.PyPsiBundle;
import com.jetbrains.python.PythonUiService;
import com.jetbrains.python.inspections.quickfix.WrapExceptTupleInParenthesesQuickFix;
import com.jetbrains.python.psi.*;
import com.jetbrains.python.psi.impl.PyBuiltinCache;
import com.jetbrains.python.psi.types.PyClassType;
@@ -279,7 +281,7 @@ public final class PyCompatibilityInspection extends PyInspection {
public void visitPyArgumentList(final @NotNull PyArgumentList node) { //PY-5588
if (node.getParent() instanceof PyClass) {
final boolean isPython2 = LanguageLevel.forElement(node).isPython2();
if (isPython2 || myVersionsToProcess.stream().anyMatch(LanguageLevel::isPython2)) {
if (isPython2 || ContainerUtil.exists(myVersionsToProcess, LanguageLevel::isPython2)) {
Arrays
.stream(node.getArguments())
.filter(PyKeywordArgument.class::isInstance)
@@ -296,7 +298,7 @@ public final class PyCompatibilityInspection extends PyInspection {
public void visitPyReferenceExpression(@NotNull PyReferenceExpression node) {
super.visitPyElement(node);
if (myVersionsToProcess.stream().anyMatch(LanguageLevel::isPy3K)) {
if (ContainerUtil.exists(myVersionsToProcess, LanguageLevel::isPy3K)) {
final String nodeText = node.getText();
if (nodeText.endsWith("iteritems") || nodeText.endsWith("iterkeys") || nodeText.endsWith("itervalues")) {
@@ -332,6 +334,42 @@ public final class PyCompatibilityInspection extends PyInspection {
}
}
@Override
public void visitPyExceptBlock(@NotNull PyExceptPart exceptExpr) {
super.visitPyExceptBlock(exceptExpr);
// TreeSet is used to sort versions
TreeSet<LanguageLevel> allVersions = new TreeSet<>(myVersionsToProcess); // compatible-with versions
allVersions.add(LanguageLevel.forElement(exceptExpr)); // dev version
boolean hasBelow300 = ContainerUtil.exists(allVersions, level -> level.isOlderThan(LanguageLevel.PYTHON30));
boolean hasBelow314 = ContainerUtil.exists(allVersions, level -> level.isOlderThan(LanguageLevel.PYTHON314));
String allVersionsStr = StringUtil.join(allVersions, LanguageLevel::toString, ", ");
PyExpression exceptClass = exceptExpr.getExceptClass();
PyExpression target = exceptExpr.getTarget();
if (exceptClass == null) return;
if (LanguageLevel.forElement(exceptExpr).isAtLeast(LanguageLevel.PYTHON314)) { // see #StatementParsing.parseExceptPart()
if (exceptClass instanceof PyTupleExpression tuple && tuple.getElements().length > 1) {
if (target != null) {
// see INSP.except.clause.missing.parens
}
else if (hasBelow300) {
// different semantics in versions <300 vs. >=314
registerProblem(exceptClass, PyPsiBundle.message("INSP.compatibility.except.clause.different.semantics", allVersionsStr));
}
else if (hasBelow314) {
// we have: `except expr1, expr2`
// versions <314 require parentheses, versions >=314 not (only if the target is specified, but irrelevant here)
LocalQuickFix quickFix = LocalQuickFix.from(new WrapExceptTupleInParenthesesQuickFix(tuple));
registerForAllMatchingVersions(level -> level.isAtLeast(LanguageLevel.PYTHON30) && level.isOlderThan(LanguageLevel.PYTHON314),
PyPsiBundle.message("INSP.compatibility.except.clause.missing.parens"), exceptClass, quickFix);
}
}
}
}
@Override
public void visitPyTargetExpression(@NotNull PyTargetExpression node) {
super.visitPyTargetExpression(node);
@@ -1,27 +1,20 @@
package com.jetbrains.python.inspections.quickfix
import com.intellij.codeInsight.intention.IntentionAction
import com.intellij.codeInspection.util.IntentionName
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiFile
import com.intellij.modcommand.ActionContext
import com.intellij.modcommand.ModPsiUpdater
import com.intellij.modcommand.PsiUpdateModCommandAction
import com.jetbrains.python.PyPsiBundle
import com.jetbrains.python.psi.LanguageLevel
import com.jetbrains.python.psi.PyElementGenerator
import com.jetbrains.python.psi.PyTupleExpression
class WrapExceptTupleInParenthesesQuickFix(val exceptPartTuple: PyTupleExpression) : IntentionAction {
class WrapExceptTupleInParenthesesQuickFix(exceptPartTuple: PyTupleExpression)
: PsiUpdateModCommandAction<PyTupleExpression>(exceptPartTuple) {
override fun getFamilyName(): String = PyPsiBundle.message("QFIX.except.clause.missing.parens")
override fun getText(): @IntentionName String = PyPsiBundle.message("QFIX.except.clause.missing.parens")
override fun isAvailable(project: Project, editor: Editor?, psiFile: PsiFile?): Boolean = true
override fun startInWriteAction(): Boolean = true
override fun invoke(project: Project, editor: Editor?, psiFile: PsiFile?) {
val generator = PyElementGenerator.getInstance(project)
override fun invoke(context: ActionContext, exceptPartTuple: PyTupleExpression, updater: ModPsiUpdater) {
val generator = PyElementGenerator.getInstance(context.project)
val level = LanguageLevel.forElement(exceptPartTuple)
val wrapped = generator.createExpressionFromText(level, "(${exceptPartTuple.text})")
exceptPartTuple.replace(wrapped)
@@ -79,7 +79,7 @@ public abstract class PyCompatibilityVisitor extends PyElementVisitor {
}
if (element != null && ",".equals(element.getText())) {
registerForAllMatchingVersions(level -> level.isPy3K() && registerForLanguageLevel(level),
registerForAllMatchingVersions(level -> level.isPy3K() && level.isOlderThan(LanguageLevel.PYTHON314) && registerForLanguageLevel(level),
PyPsiBundle.message("INSP.compatibility.feature.support.this.syntax"),
node,
new ReplaceExceptPartQuickFix());
@@ -1,4 +1,4 @@
try:
do_smth()
<warning descr="Python versions 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13, 3.14 do not support this syntax">except ImportError, ImportWarning:
<warning descr="Python versions 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13 do not support this syntax">except ImportError, ImportWarning:
do()</warning>
@@ -0,0 +1,4 @@
try:
pass
<error descr="Python version 3.13 does not support this syntax">except IOError, OSError:
pass</error>
@@ -0,0 +1,4 @@
try:
pass
except <warning descr="Different semantics of comma-separated elements in Python versions 2.7, 3.14.">IOError, OSError</warning>:
pass
@@ -0,0 +1,4 @@
try:
pass
except <warning descr="Python version 3.7 does not support missing parentheses in except clauses">IOError, OSError</warning>:
pass
@@ -0,0 +1,4 @@
try:
f = open('myfile.txt')
except IOError, OtherError: # same code as pre 314
pass
@@ -0,0 +1,39 @@
PyFile:TryExceptMultipleNoParensPost314.py
PyTryExceptStatement
PyTryPart
PsiElement(Py:TRY_KEYWORD)('try')
PsiElement(Py:COLON)(':')
PsiWhiteSpace('\n ')
PyStatementList
PyAssignmentStatement
PyTargetExpression: f
PsiElement(Py:IDENTIFIER)('f')
PsiWhiteSpace(' ')
PsiElement(Py:EQ)('=')
PsiWhiteSpace(' ')
PyCallExpression: open
PyReferenceExpression: open
PsiElement(Py:IDENTIFIER)('open')
PyArgumentList
PsiElement(Py:LPAR)('(')
PyStringLiteralExpression: myfile.txt
PsiElement(Py:SINGLE_QUOTED_STRING)(''myfile.txt'')
PsiElement(Py:RPAR)(')')
PsiWhiteSpace('\n')
PyExceptPart
PsiElement(Py:EXCEPT_KEYWORD)('except')
PsiWhiteSpace(' ')
PyTupleExpression
PyReferenceExpression: IOError
PsiElement(Py:IDENTIFIER)('IOError')
PsiElement(Py:COMMA)(',')
PsiWhiteSpace(' ')
PyReferenceExpression: OtherError
PsiElement(Py:IDENTIFIER)('OtherError')
PsiElement(Py:COLON)(':')
PsiWhiteSpace(' ')
PsiComment(Py:END_OF_LINE_COMMENT)('# same code as pre 314')
PsiWhiteSpace('\n ')
PyStatementList
PyPassStatement
PsiElement(Py:PASS_KEYWORD)('pass')
@@ -0,0 +1,4 @@
try:
f = open('myfile.txt')
except IOError, OtherError: # same code as post 314
pass
@@ -0,0 +1,38 @@
PyFile:TryExceptMultipleNoParensPre314.py
PyTryExceptStatement
PyTryPart
PsiElement(Py:TRY_KEYWORD)('try')
PsiElement(Py:COLON)(':')
PsiWhiteSpace('\n ')
PyStatementList
PyAssignmentStatement
PyTargetExpression: f
PsiElement(Py:IDENTIFIER)('f')
PsiWhiteSpace(' ')
PsiElement(Py:EQ)('=')
PsiWhiteSpace(' ')
PyCallExpression: open
PyReferenceExpression: open
PsiElement(Py:IDENTIFIER)('open')
PyArgumentList
PsiElement(Py:LPAR)('(')
PyStringLiteralExpression: myfile.txt
PsiElement(Py:SINGLE_QUOTED_STRING)(''myfile.txt'')
PsiElement(Py:RPAR)(')')
PsiWhiteSpace('\n')
PyExceptPart
PsiElement(Py:EXCEPT_KEYWORD)('except')
PsiWhiteSpace(' ')
PyReferenceExpression: IOError
PsiElement(Py:IDENTIFIER)('IOError')
PsiElement(Py:COMMA)(',')
PsiWhiteSpace(' ')
PyTargetExpression: OtherError
PsiElement(Py:IDENTIFIER)('OtherError')
PsiElement(Py:COLON)(':')
PsiWhiteSpace(' ')
PsiComment(Py:END_OF_LINE_COMMENT)('# same code as post 314')
PsiWhiteSpace('\n ')
PyStatementList
PyPassStatement
PsiElement(Py:PASS_KEYWORD)('pass')
@@ -0,0 +1,4 @@
try:
f = open('myfile.txt')
except (IOError, OtherError):
pass
@@ -0,0 +1,40 @@
PyFile:TryExceptMultipleWithParens.py
PyTryExceptStatement
PyTryPart
PsiElement(Py:TRY_KEYWORD)('try')
PsiElement(Py:COLON)(':')
PsiWhiteSpace('\n ')
PyStatementList
PyAssignmentStatement
PyTargetExpression: f
PsiElement(Py:IDENTIFIER)('f')
PsiWhiteSpace(' ')
PsiElement(Py:EQ)('=')
PsiWhiteSpace(' ')
PyCallExpression: open
PyReferenceExpression: open
PsiElement(Py:IDENTIFIER)('open')
PyArgumentList
PsiElement(Py:LPAR)('(')
PyStringLiteralExpression: myfile.txt
PsiElement(Py:SINGLE_QUOTED_STRING)(''myfile.txt'')
PsiElement(Py:RPAR)(')')
PsiWhiteSpace('\n')
PyExceptPart
PsiElement(Py:EXCEPT_KEYWORD)('except')
PsiWhiteSpace(' ')
PyParenthesizedExpression
PsiElement(Py:LPAR)('(')
PyTupleExpression
PyReferenceExpression: IOError
PsiElement(Py:IDENTIFIER)('IOError')
PsiElement(Py:COMMA)(',')
PsiWhiteSpace(' ')
PyReferenceExpression: OtherError
PsiElement(Py:IDENTIFIER)('OtherError')
PsiElement(Py:RPAR)(')')
PsiElement(Py:COLON)(':')
PsiWhiteSpace('\n ')
PyStatementList
PyPassStatement
PsiElement(Py:PASS_KEYWORD)('pass')
@@ -1,12 +1,17 @@
// Copyright 2000-2017 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.inspections;
import com.intellij.psi.PsiFile;
import com.intellij.testFramework.LightProjectDescriptor;
import com.intellij.util.containers.ContainerUtil;
import com.jetbrains.python.fixtures.PyInspectionTestCase;
import com.jetbrains.python.psi.LanguageLevel;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Arrays;
import java.util.List;
/**
* User : catherine
*/
@@ -183,6 +188,21 @@ public class PyCompatibilityInspectionTest extends PyInspectionTestCase {
doTest();
}
// PY-84077
public void testTryExcept313Against27() {
testAgainstVersions(LanguageLevel.PYTHON313, LanguageLevel.PYTHON27);
}
// PY-84077
public void testTryExcept314Against27() {
testAgainstVersions(LanguageLevel.PYTHON314, LanguageLevel.PYTHON27);
}
// PY-84077
public void testTryExcept314Against37() {
testAgainstVersions(LanguageLevel.PYTHON314, LanguageLevel.PYTHON37);
}
// PY-26510
public void testTryFinallyEmptyRaisePy2() {
doTest();
@@ -289,6 +309,23 @@ public class PyCompatibilityInspectionTest extends PyInspectionTestCase {
runWithLanguageLevel(level, this::doTest);
}
void testAgainstVersions(LanguageLevel runLevel, LanguageLevel ... compLevels) {
assertNotNull(compLevels);
List<LanguageLevel> compLevelsList = Arrays.asList(compLevels);
assertNotEmpty(compLevelsList);
runWithLanguageLevel(runLevel, () -> {
PsiFile currentFile = myFixture.configureByFile(getTestFilePath());
PyCompatibilityInspection inspection = new PyCompatibilityInspection();
inspection.ourVersions.clear();
inspection.ourVersions.addAll(ContainerUtil.map(compLevelsList, l -> l.toPythonVersion()));
myFixture.enableInspections(inspection);
myFixture.checkHighlighting(isWarning(), isInfo(), isWeakWarning());
assertSdkRootsNotParsed(currentFile);
});
}
@NotNull
@Override
protected Class<? extends PyInspection> getInspectionClass() {
@@ -82,6 +82,30 @@ public class PythonParsingTest extends ParsingTestCase {
doTest();
}
public void testTryExceptAs() { // PY-293
doTest();
}
// PY-52930
public void testTryExceptStarNoExpression() {
doTest();
}
// PY-84077
public void testTryExceptMultipleNoParensPre314() {
doTest(LanguageLevel.PYTHON26);
}
// PY-84077
public void testTryExceptMultipleNoParensPost314() {
doTest(LanguageLevel.PYTHON314);
}
// PY-84077
public void testTryExceptMultipleWithParens() {
doTest();
}
public void testTryFinally() {
doTest();
}
@@ -112,15 +136,6 @@ public class PythonParsingTest extends ParsingTestCase {
doTest();
}
public void testTryExceptAs() { // PY-293
doTest();
}
// PY-52930
public void testTryExceptStarNoExpression() {
doTest();
}
public void testPrintAsFunction26() {
doTest(LanguageLevel.PYTHON26);
}