PY-84077 - Support PEP 758 – Allow except and except* expressions without parentheses

- adjust parser to support comma separated list of error classes
- add problem annotation and quick fix for missing parentheses
- add new tests, adjust old tests

GitOrigin-RevId: 545f3597a488f85ba2ff17da0a389f2aed226406
This commit is contained in:
Marcus Mews
2025-09-15 12:58:49 +00:00
committed by intellij-monorepo-bot
parent 99917efcba
commit dcb6914323
11 changed files with 151 additions and 35 deletions

View File

@@ -793,26 +793,7 @@ public class StatementParsing extends Parsing implements ITokenTypeRemapper {
if (myBuilder.getTokenType() == PyTokenTypes.EXCEPT_KEYWORD) {
haveExceptClause = true;
while (myBuilder.getTokenType() == PyTokenTypes.EXCEPT_KEYWORD) {
final SyntaxTreeBuilder.Marker exceptBlock = myBuilder.mark();
myBuilder.advanceLexer();
boolean star = matchToken(PyTokenTypes.MULT);
if (myBuilder.getTokenType() != PyTokenTypes.COLON) {
if (!getExpressionParser().parseSingleExpression(false)) {
myBuilder.error(PyParsingBundle.message("PARSE.expected.expression"));
}
if (myBuilder.getTokenType() == PyTokenTypes.COMMA || myBuilder.getTokenType() == PyTokenTypes.AS_KEYWORD) {
myBuilder.advanceLexer();
if (!getExpressionParser().parseSingleExpression(true)) {
myBuilder.error(PyParsingBundle.message("PARSE.expected.expression"));
}
}
}
else if (star) {
myBuilder.error(PyParsingBundle.message("PARSE.expected.expression"));
}
parseColonAndSuite();
exceptBlock.done(PyElementTypes.EXCEPT_PART);
parseExceptClause();
}
final SyntaxTreeBuilder.Marker elsePart = myBuilder.mark();
if (myBuilder.getTokenType() == PyTokenTypes.ELSE_KEYWORD) {
@@ -841,6 +822,45 @@ public class StatementParsing extends Parsing implements ITokenTypeRemapper {
statement.done(PyElementTypes.TRY_EXCEPT_STATEMENT);
}
private void parseExceptClause() {
final SyntaxTreeBuilder.Marker exceptBlock = myBuilder.mark();
myBuilder.advanceLexer();
boolean star = matchToken(PyTokenTypes.MULT);
if (myBuilder.getTokenType() != PyTokenTypes.COLON) {
if (myContext.getLanguageLevel().isAtLeast(LanguageLevel.PYTHON314)) {
// Python 3.14, support PEP-758 syntax: except ImportError, OtherError: ...
if (!getExpressionParser().parseExpressionOptional(false)) {
myBuilder.error(PyParsingBundle.message("PARSE.expected.expression"));
}
if (myBuilder.getTokenType() == PyTokenTypes.AS_KEYWORD) {
myBuilder.advanceLexer();
if (!getExpressionParser().parseSingleExpression(true)) {
myBuilder.error(PyParsingBundle.message("PARSE.expected.expression"));
}
}
}
else {
// Python 2, support syntax: except ImportError, targetE: ...
if (!getExpressionParser().parseSingleExpression(false)) {
myBuilder.error(PyParsingBundle.message("PARSE.expected.expression"));
}
// support Py3K syntax with 'as' to show an error with a quickfix
if (myBuilder.getTokenType() == PyTokenTypes.COMMA || myBuilder.getTokenType() == PyTokenTypes.AS_KEYWORD) {
myBuilder.advanceLexer();
if (!getExpressionParser().parseSingleExpression(true)) {
myBuilder.error(PyParsingBundle.message("PARSE.expected.expression"));
}
}
}
}
else if (star) {
myBuilder.error(PyParsingBundle.message("PARSE.expected.expression"));
}
parseColonAndSuite();
exceptBlock.done(PyElementTypes.EXCEPT_PART);
}
private void parseColonAndSuite() {
if (expectColon()) {
parseSuite();

View File

@@ -957,6 +957,10 @@ INSP.NAME.bad.except.clauses.order=Wrong order of 'except' clauses
INSP.bad.except.exception.class.already.caught=Exception class ''{0}'' has already been caught
INSP.bad.except.superclass.of.exception.class.already.caught=''{0}'', superclass of the exception class ''{1}'', has already been caught
# PyExceptClauseMissingParenthesesInspection
INSP.except.clause.missing.parens=Parentheses are required when specifying a target for multiple exception classes
QFIX.except.clause.missing.parens=Wrap exception tuple in parentheses
#PyGlobalUndefinedInspection
INSP.NAME.global.undefined=Global variable is not defined at the module level
INSP.global.variable.undefined=Global variable ''{0}'' is undefined at the module level

View File

@@ -0,0 +1,29 @@
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.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 {
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)
val level = LanguageLevel.forElement(exceptPartTuple)
val wrapped = generator.createExpressionFromText(level, "(${exceptPartTuple.text})")
exceptPartTuple.replace(wrapped)
}
}

View File

@@ -6,6 +6,7 @@ import com.intellij.psi.PsiElement;
import com.intellij.psi.util.PsiTreeUtil;
import com.jetbrains.python.PyPsiBundle;
import com.jetbrains.python.PyTokenTypes;
import com.jetbrains.python.inspections.quickfix.WrapExceptTupleInParenthesesQuickFix;
import com.jetbrains.python.psi.*;
import com.jetbrains.python.psi.impl.PyPsiUtils;
import org.jetbrains.annotations.NotNull;
@@ -60,12 +61,26 @@ public final class PyTryExceptAnnotator extends PyAnnotator {
@Override
public void visitPyExceptBlock(@NotNull PyExceptPart node) {
if (!node.isStar()) return;
if (node.isStar()) {
var exceptClass = node.getExceptClass();
var exceptionGroup = tryGetExceptionGroupInExpression(exceptClass);
if (exceptionGroup != null) {
getHolder().newAnnotation(HighlightSeverity.ERROR, PyPsiBundle.message("ANN.exception.group.in.star.except")).range(exceptionGroup)
.create();
}
}
var exceptClass = node.getExceptClass();
var exceptionGroup = tryGetExceptionGroupInExpression(exceptClass);
if (exceptionGroup != null) {
getHolder().newAnnotation(HighlightSeverity.ERROR, PyPsiBundle.message("ANN.exception.group.in.star.except")).range(exceptionGroup).create();
// Add PEP-758 Py314+ missing parentheses check: except Error1, Error2 as e:
var level = LanguageLevel.forElement(node);
if (!level.isPy3K()) return;
if (node.getTarget() == null) return;
var exceptExpr = node.getExceptClass();
if (exceptExpr instanceof PyParenthesizedExpression) return;
if (exceptExpr instanceof PyTupleExpression tuple && tuple.getElements().length > 1) {
getHolder().newAnnotation(HighlightSeverity.ERROR, PyPsiBundle.message("INSP.except.clause.missing.parens"))
.range(tuple)
.withFix(new WrapExceptTupleInParenthesesQuickFix(tuple))
.create();
}
}

View File

@@ -0,0 +1,5 @@
def f():
try:
pass
except <error descr="Parentheses are required when specifying a target for multiple exception classes">ValueError, TypeError</error> as e:
pass

View File

@@ -0,0 +1,4 @@
try:
pass
except ImportError,<caret> AccessError as e:
pass

View File

@@ -0,0 +1,4 @@
try:
pass
except (ImportError, AccessError) as e:
pass

View File

@@ -1,9 +1,15 @@
# try and friends
<stmt>try:
p<body>ass
e<ex1>xcept ArithmeticError, e:
e<ex0>xcept ArithmeticError:
pass
e<ex2>xcept:
e<ex1>xcept ArithmeticError, ImportError:
pass
e<ex2>xcept ArithmeticError as e2:
pass
e<ex3>xcept (ArithmeticError, ImportError) as e3:
pass
e<ex4>xcept:
pass
e<else>lse:
pass

View File

@@ -144,7 +144,7 @@ public class PyStatementPartsTest extends LightMarkedTestCase {
public void testTry() {
Map<String, PsiElement> marks = loadTest();
Assert.assertEquals(6, marks.size());
Assert.assertEquals(9, marks.size());
PsiElement elt = marks.get("<stmt>").getParent().getParent(); // keyword -> part -> stmt
Assert.assertTrue(elt instanceof PyTryExceptStatement);
@@ -155,13 +155,33 @@ public class PyStatementPartsTest extends LightMarkedTestCase {
Assert.assertNotNull(stmt_list);
Assert.assertEquals(marks.get("<body>").getParent().getParent(), stmt_list); // keyword -> stmt -> stmt_list
PyExceptPart exc_part = stmt.getExceptParts()[0];
Assert.assertEquals("ArithmeticError", exc_part.getExceptClass().getText());
Assert.assertEquals(marks.get("<ex1>").getParent(), exc_part);
PyExceptPart exc_part0 = stmt.getExceptParts()[0];
Assert.assertEquals("ArithmeticError", exc_part0.getExceptClass().getText());
Assert.assertNull(exc_part0.getTarget());
Assert.assertEquals(marks.get("<ex0>").getParent(), exc_part0);
exc_part = (PyExceptPart)marks.get("<ex2>").getParent(); // keyword -> part
Assert.assertEquals(stmt.getExceptParts()[1], exc_part);
Assert.assertNull(exc_part.getExceptClass());
PyExceptPart exc_part1 = stmt.getExceptParts()[1];
Assert.assertEquals("ArithmeticError, ImportError", exc_part1.getExceptClass().getText());
Assert.assertNull(exc_part1.getTarget());
Assert.assertEquals(marks.get("<ex1>").getParent(), exc_part1);
PyExceptPart exc_part2 = stmt.getExceptParts()[2];
Assert.assertEquals("ArithmeticError", exc_part2.getExceptClass().getText());
Assert.assertNotNull(exc_part2.getTarget());
Assert.assertEquals("e2", exc_part2.getTarget().getText());
Assert.assertEquals(marks.get("<ex2>").getParent(), exc_part2);
PyExceptPart exc_part3 = stmt.getExceptParts()[3];
Assert.assertEquals("(ArithmeticError, ImportError)", exc_part3.getExceptClass().getText());
Assert.assertNotNull(exc_part3.getTarget());
Assert.assertEquals("e3", exc_part3.getTarget().getText());
Assert.assertEquals(marks.get("<ex3>").getParent(), exc_part3);
PyExceptPart exc_part4 = stmt.getExceptParts()[4];
Assert.assertNull(exc_part4.getExceptClass());
Assert.assertNull(exc_part4.getTarget());
Assert.assertEquals(marks.get("<ex4>").getParent(), exc_part4);
Assert.assertNull(exc_part4.getExceptClass());
elt = marks.get("<else>").getParent(); // keyword -> part
Assert.assertTrue(elt instanceof PyElsePart);

View File

@@ -301,6 +301,11 @@ public class PythonHighlightingTest extends PyTestCase {
doTest(LanguageLevel.getLatest(), false, false);
}
// PY-84077
public void testExceptClauseMissingParentheses() {
doTest(LanguageLevel.getLatest(), false, false);
}
// PY-52930
public void testContinueBreakReturnInExceptStar() {
doTest(LanguageLevel.getLatest(), false, false);

View File

@@ -84,7 +84,11 @@ public class PyIntentionTest extends PyTestCase {
}
public void testReplaceExceptPart() {
doTest(PyPsiBundle.message("INTN.convert.except.to"));
runWithLanguageLevel(LanguageLevel.PYTHON35, () -> doTest(PyPsiBundle.message("INTN.convert.except.to")));
}
public void testExceptPartAddMissingParentheses() {
doTest(PyPsiBundle.message("QFIX.except.clause.missing.parens"));
}
public void testConvertBuiltins() {