diff --git a/python/extractor/semmle/python/parser/ast.py b/python/extractor/semmle/python/parser/ast.py index e1843131554a..0712bf08ae45 100644 --- a/python/extractor/semmle/python/parser/ast.py +++ b/python/extractor/semmle/python/parser/ast.py @@ -981,7 +981,16 @@ def visit_except_clause(self, node): if len(node.children) > 1: type = self.visit(node.children[1], LOAD) if len(node.children) > 3: - name = self.visit(node.children[3], STORE) + if is_token(node.children[2], "as"): + name = self.visit(node.children[3], STORE) + else: + # PEP 758 (Python 3.14+): `except A, B:` is an unparenthesized + # tuple of exception types, not a Python 2 alias binding. The + # grammar rule `'except' [test [(',' | 'as') test]]` is shared + # between both readings, so the separator token decides. + elts = [type, self.visit(node.children[3], LOAD)] + type = ast.Tuple(elts, LOAD) + set_location(type, node.children[1].start, node.children[3].end) return type, name def visit_del_stmt(self, node): diff --git a/python/extractor/tests/parser/exceptions_relaxed.py b/python/extractor/tests/parser/exceptions_relaxed.py new file mode 100644 index 000000000000..a0dea76dfa81 --- /dev/null +++ b/python/extractor/tests/parser/exceptions_relaxed.py @@ -0,0 +1,10 @@ +try: + a +except b, c: + d +except (e, f): + g +except h as i: + j +except k: + l diff --git a/python/ql/lib/change-notes/2026-08-19-legacy-parser-relaxed-except.md b/python/ql/lib/change-notes/2026-08-19-legacy-parser-relaxed-except.md new file mode 100644 index 000000000000..bd4bd599f568 --- /dev/null +++ b/python/ql/lib/change-notes/2026-08-19-legacy-parser-relaxed-except.md @@ -0,0 +1,4 @@ +--- +category: fix +--- +* Fixed the extraction of PEP 758 `except A, B:` clauses by the default (non-tree-sitter) Python parser. Previously the second exception type was extracted as a Python 2 style alias binding, so it was recorded as a `Store` rather than a use. This caused false positives from queries that reason about whether a name is used, such as `py/unused-import`. diff --git a/python/ql/test/query-tests/Imports/unused/UnusedImport.expected b/python/ql/test/query-tests/Imports/unused/UnusedImport.expected index 2f27961d92e3..801305defafd 100644 --- a/python/ql/test/query-tests/Imports/unused/UnusedImport.expected +++ b/python/ql/test/query-tests/Imports/unused/UnusedImport.expected @@ -6,3 +6,4 @@ | imports_test.py:27:1:27:25 | Import | Import of 'func2' is not used. | | imports_test.py:34:1:34:14 | Import | Import of 'module2' is not used. | | imports_test.py:116:1:116:41 | Import | Import of 'not_a_fixture' is not used. | +| relaxed_except.py:12:1:12:68 | Import | Import of 'NeverUsed' is not used. | diff --git a/python/ql/test/query-tests/Imports/unused/relaxed_except.py b/python/ql/test/query-tests/Imports/unused/relaxed_except.py new file mode 100644 index 000000000000..0f1f29d71ceb --- /dev/null +++ b/python/ql/test/query-tests/Imports/unused/relaxed_except.py @@ -0,0 +1,26 @@ +# PEP 758 allows unparenthesized exception types when there is no `as` clause. +# Every name below is used as an exception type, so no import here is unused. +# `NeverUsed` is imported and never used, and is the one expected result. +# +# Each name appears in exactly one clause on purpose: a name that also appeared +# in a parenthesized clause would be a use regardless, and would mask the +# behaviour under test. +# +# This file deliberately contains no `except A, B, C:` clause. Three or more +# unparenthesized types fail the default parser, which sends the whole file to +# the tree-sitter parser and would likewise mask it. +from relaxed_except_defs import Alpha, Beta, Delta, Gamma, NeverUsed + + +def unparenthesized(): + try: + pass + except Alpha, Beta: + raise + + +def parenthesized(): + try: + pass + except (Gamma, Delta): + raise diff --git a/python/ql/test/query-tests/Imports/unused/relaxed_except_defs.py b/python/ql/test/query-tests/Imports/unused/relaxed_except_defs.py new file mode 100644 index 000000000000..1c6e63828666 --- /dev/null +++ b/python/ql/test/query-tests/Imports/unused/relaxed_except_defs.py @@ -0,0 +1,26 @@ +class Alpha(Exception): + pass + + +class Beta(Exception): + pass + + +class Gamma(Exception): + pass + + +class Delta(Exception): + pass + + +class Epsilon(Exception): + pass + + +class NeverUsed(Exception): + pass + + +class Zeta(Exception): + pass diff --git a/python/ql/test/query-tests/Imports/unused/relaxed_except_long.py b/python/ql/test/query-tests/Imports/unused/relaxed_except_long.py new file mode 100644 index 000000000000..cadecb48cd3c --- /dev/null +++ b/python/ql/test/query-tests/Imports/unused/relaxed_except_long.py @@ -0,0 +1,17 @@ +# Three or more unparenthesized exception types. These fail the default parser +# and are extracted by the tree-sitter parser instead; all names are still uses. +from relaxed_except_defs import Delta, Epsilon, Gamma, Zeta + + +def three(): + try: + pass + except Gamma, Delta, Epsilon: + raise + + +def four(): + try: + pass + except Gamma, Delta, Epsilon, Zeta: + raise