From 66573bb5951d60ad88d20fce4989345ee5d1aeaf Mon Sep 17 00:00:00 2001 From: Alex Ausch Date: Wed, 19 Aug 2026 17:11:54 +0200 Subject: [PATCH 1/2] Python: fix PEP 758 `except A, B:` in the default parser The grammar rule shared by both readings is except_clause: 'except' [test [(',' | 'as') test]] and `visit_except_clause` ignored the separator token, always treating the fourth child as an alias to bind. So `except A, B:` extracted `B` as a Store rather than a use, which is the Python 2 reading. Queries that reason about whether a name is used then report false positives; `py/unused-import` flags the import of `B` as unused. The tree-sitter parser already extracts this as a tuple of exception types (#20990), so the two parsers disagreed. `tests/parser/exceptions_relaxed.py` is an unsuffixed parser test, which asserts the two parsers produce identical ASTs; it fails without this change. With the fix, the default parser reproduces the existing `tests/parser/exceptions_new.expected` byte for byte, and of the 37 parser test files only the two containing PEP 758 syntax change at all. Co-Authored-By: Claude Opus 5 (1M context) --- python/extractor/semmle/python/parser/ast.py | 11 ++++++++++- python/extractor/tests/parser/exceptions_relaxed.py | 10 ++++++++++ .../2026-08-19-legacy-parser-relaxed-except.md | 4 ++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 python/extractor/tests/parser/exceptions_relaxed.py create mode 100644 python/ql/lib/change-notes/2026-08-19-legacy-parser-relaxed-except.md 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`. From 896d8d78b45d489a317cace26b4219399653e4e3 Mon Sep 17 00:00:00 2001 From: Alex Ausch Date: Thu, 20 Aug 2026 10:53:23 +0200 Subject: [PATCH 2/2] Python: add query tests for unparenthesized except chains of every length The parser test added with the fix pins the AST. These pin the behaviour a user actually sees, through the real extraction path including the tree-sitter fallback, and they cover chains longer than two. Chains of three or more behave differently from chains of two, which is worth having written down. `except A, B, C:` fails the default parser outright, so `Module.py_ast` falls back to tree-sitter and the result is already correct. `except A, B:` parses successfully under the Python 2 reading, so the fallback never fires and the bad AST reaches the queries. That is why only the two-type form produced a false positive. It also means the two cases cannot share a file: any three-type clause sends the whole file to tree-sitter and masks the two-type behaviour. Hence relaxed_except.py and relaxed_except_long.py, with a comment in each saying so. Each name is used in exactly one clause for the same reason -- a name reused in a parenthesized clause is a use regardless, and hides the defect. Verified by reverting the extractor fix in a 2.26.3 bundle: relaxed_except.py then reports `Import of 'Beta' is not used.` and the test fails. Co-Authored-By: Claude Opus 5 (1M context) --- .../Imports/unused/UnusedImport.expected | 1 + .../Imports/unused/relaxed_except.py | 26 +++++++++++++++++++ .../Imports/unused/relaxed_except_defs.py | 26 +++++++++++++++++++ .../Imports/unused/relaxed_except_long.py | 17 ++++++++++++ 4 files changed, 70 insertions(+) create mode 100644 python/ql/test/query-tests/Imports/unused/relaxed_except.py create mode 100644 python/ql/test/query-tests/Imports/unused/relaxed_except_defs.py create mode 100644 python/ql/test/query-tests/Imports/unused/relaxed_except_long.py 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