From dcb691432396e1bfb57d266f859b81cfddc435ac Mon Sep 17 00:00:00 2001 From: Marcus Mews Date: Mon, 15 Sep 2025 12:58:49 +0000 Subject: [PATCH] =?UTF-8?q?PY-84077=20-=20Support=20PEP=20758=20=E2=80=93?= =?UTF-8?q?=20Allow=20except=20and=20except*=20expressions=20without=20par?= =?UTF-8?q?entheses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../python/parsing/StatementParsing.java | 60 ++++++++++++------- .../resources/messages/PyPsiBundle.properties | 4 ++ .../WrapExceptTupleInParenthesesQuickFix.kt | 29 +++++++++ .../validation/PyTryExceptAnnotator.java | 25 ++++++-- .../exceptClauseMissingParentheses.py | 5 ++ .../exceptPartAddMissingParentheses.py | 4 ++ .../exceptPartAddMissingParentheses_after.py | 4 ++ python/testData/psi/parts/Try.py | 10 +++- .../python/PyStatementPartsTest.java | 34 ++++++++--- .../python/PythonHighlightingTest.java | 5 ++ .../python/intentions/PyIntentionTest.java | 6 +- 11 files changed, 151 insertions(+), 35 deletions(-) create mode 100644 python/python-psi-impl/src/com/jetbrains/python/inspections/quickfix/WrapExceptTupleInParenthesesQuickFix.kt create mode 100644 python/testData/highlighting/exceptClauseMissingParentheses.py create mode 100644 python/testData/intentions/exceptPartAddMissingParentheses.py create mode 100644 python/testData/intentions/exceptPartAddMissingParentheses_after.py diff --git a/python/python-parser/src/com/jetbrains/python/parsing/StatementParsing.java b/python/python-parser/src/com/jetbrains/python/parsing/StatementParsing.java index 08ced1267dff..62ceee0218a8 100644 --- a/python/python-parser/src/com/jetbrains/python/parsing/StatementParsing.java +++ b/python/python-parser/src/com/jetbrains/python/parsing/StatementParsing.java @@ -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(); diff --git a/python/python-psi-impl/resources/messages/PyPsiBundle.properties b/python/python-psi-impl/resources/messages/PyPsiBundle.properties index d8603dc30d8b..1dccfbf47525 100644 --- a/python/python-psi-impl/resources/messages/PyPsiBundle.properties +++ b/python/python-psi-impl/resources/messages/PyPsiBundle.properties @@ -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 diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/quickfix/WrapExceptTupleInParenthesesQuickFix.kt b/python/python-psi-impl/src/com/jetbrains/python/inspections/quickfix/WrapExceptTupleInParenthesesQuickFix.kt new file mode 100644 index 000000000000..871c4b20956c --- /dev/null +++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/quickfix/WrapExceptTupleInParenthesesQuickFix.kt @@ -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) + } +} \ No newline at end of file diff --git a/python/python-psi-impl/src/com/jetbrains/python/validation/PyTryExceptAnnotator.java b/python/python-psi-impl/src/com/jetbrains/python/validation/PyTryExceptAnnotator.java index 7e675fdb5e1f..6d4e744cfc37 100644 --- a/python/python-psi-impl/src/com/jetbrains/python/validation/PyTryExceptAnnotator.java +++ b/python/python-psi-impl/src/com/jetbrains/python/validation/PyTryExceptAnnotator.java @@ -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(); } } diff --git a/python/testData/highlighting/exceptClauseMissingParentheses.py b/python/testData/highlighting/exceptClauseMissingParentheses.py new file mode 100644 index 000000000000..6895a4a0aba5 --- /dev/null +++ b/python/testData/highlighting/exceptClauseMissingParentheses.py @@ -0,0 +1,5 @@ +def f(): + try: + pass + except ValueError, TypeError as e: + pass \ No newline at end of file diff --git a/python/testData/intentions/exceptPartAddMissingParentheses.py b/python/testData/intentions/exceptPartAddMissingParentheses.py new file mode 100644 index 000000000000..abb9c58c739f --- /dev/null +++ b/python/testData/intentions/exceptPartAddMissingParentheses.py @@ -0,0 +1,4 @@ +try: + pass +except ImportError, AccessError as e: + pass \ No newline at end of file diff --git a/python/testData/intentions/exceptPartAddMissingParentheses_after.py b/python/testData/intentions/exceptPartAddMissingParentheses_after.py new file mode 100644 index 000000000000..003b7e03f33d --- /dev/null +++ b/python/testData/intentions/exceptPartAddMissingParentheses_after.py @@ -0,0 +1,4 @@ +try: + pass +except (ImportError, AccessError) as e: + pass \ No newline at end of file diff --git a/python/testData/psi/parts/Try.py b/python/testData/psi/parts/Try.py index 2831fcf4c3e7..ff2f41c2634a 100644 --- a/python/testData/psi/parts/Try.py +++ b/python/testData/psi/parts/Try.py @@ -1,9 +1,15 @@ # try and friends try: pass -except ArithmeticError, e: +except ArithmeticError: pass -except: +except ArithmeticError, ImportError: + pass +except ArithmeticError as e2: + pass +except (ArithmeticError, ImportError) as e3: + pass +except: pass else: pass diff --git a/python/testSrc/com/jetbrains/python/PyStatementPartsTest.java b/python/testSrc/com/jetbrains/python/PyStatementPartsTest.java index dc345c78e610..10651b3f344e 100644 --- a/python/testSrc/com/jetbrains/python/PyStatementPartsTest.java +++ b/python/testSrc/com/jetbrains/python/PyStatementPartsTest.java @@ -144,7 +144,7 @@ public class PyStatementPartsTest extends LightMarkedTestCase { public void testTry() { Map marks = loadTest(); - Assert.assertEquals(6, marks.size()); + Assert.assertEquals(9, marks.size()); PsiElement elt = marks.get("").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("").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("").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("").getParent(), exc_part0); - exc_part = (PyExceptPart)marks.get("").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("").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("").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("").getParent(), exc_part3); + + PyExceptPart exc_part4 = stmt.getExceptParts()[4]; + Assert.assertNull(exc_part4.getExceptClass()); + Assert.assertNull(exc_part4.getTarget()); + Assert.assertEquals(marks.get("").getParent(), exc_part4); + Assert.assertNull(exc_part4.getExceptClass()); elt = marks.get("").getParent(); // keyword -> part Assert.assertTrue(elt instanceof PyElsePart); diff --git a/python/testSrc/com/jetbrains/python/PythonHighlightingTest.java b/python/testSrc/com/jetbrains/python/PythonHighlightingTest.java index 78caab9b8e41..b8aea21d8a27 100644 --- a/python/testSrc/com/jetbrains/python/PythonHighlightingTest.java +++ b/python/testSrc/com/jetbrains/python/PythonHighlightingTest.java @@ -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); diff --git a/python/testSrc/com/jetbrains/python/intentions/PyIntentionTest.java b/python/testSrc/com/jetbrains/python/intentions/PyIntentionTest.java index db30a3a644df..8c3a944eabc8 100644 --- a/python/testSrc/com/jetbrains/python/intentions/PyIntentionTest.java +++ b/python/testSrc/com/jetbrains/python/intentions/PyIntentionTest.java @@ -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() {