Python: PEP 758 except A, B: extracted as a Python 2 alias, causing py/unused-import false positives #22387
Description
Description
The default Python extractor (the blib2to3-based parser, not the tree-sitter one) extracts a PEP 758 unparenthesized two-type except clause using the Python 2 reading: the second exception type is recorded as an alias binding (Store) rather than as a use (Load).
Any query that reasons about whether a name is used then misfires. The one I hit is py/unused-import.
PEP 758 is Final and shipped in Python 3.14, and ruff format actively rewrites except (A, B): to except A, B: for projects targeting py314, so this is reachable in ordinary formatted code.
Reproduction
Verified with CodeQL 2.26.3 (codeql-bundle-v2.26.3, the current bundle) on macOS.
errs.py:
class Alpha(Exception):
pass
class Beta(Exception):
passunparenthesized.py:
from errs import Alpha, Beta
def f():
try:
pass
except Alpha, Beta:
raiseparenthesized.py — identical but except (Alpha, Beta):.
codeql database create db --language=python --source-root=src
codeql database analyze db --format=csv --output=res.csv \
--rerun codeql/python-queries:Imports/UnusedImport.ql
Result:
"Unused import",...,"Import of 'Beta' is not used.","/unparenthesized.py","1","1","1","28"
parenthesized.py is clean. The only difference between the two files is the parentheses.
Root cause
Probing the database shows the contexts differ:
| file | line 7 | Alpha |
Beta |
|---|---|---|---|
parenthesized.py |
except (Alpha, Beta): |
Load | Load |
unparenthesized.py |
except Alpha, Beta: |
Load | Store |
and the AST shape confirms the Python 2 reading:
| file | ExceptStmt.getType() |
ExceptStmt.getName() |
|---|---|---|
unparenthesized.py:7 |
Alpha (a Name) |
Beta |
parenthesized.py:7 |
Tuple |
none |
The grammar rule in blib2to3/Grammar.txt is shared between the two readings:
except_clause: 'except' [test [(',' | 'as') test]]
and semmle/python/parser/ast.py::visit_except_clause never inspects the separator token:
def visit_except_clause(self, node):
type, name = None, None
if len(node.children) > 1:
type = self.visit(node.children[1], LOAD)
if len(node.children) > 3:
name = self.visit(node.children[3], STORE) # assumes children[2] is 'as'
return type, nameSo whenever there are four children, the fourth is bound as an alias regardless of whether the separator was , or as.
Scope
Only the two-type unparenthesized form is affected. except A, B, C: extracts correctly (three types, no alias), presumably because it fails this rule and reaches a different parse. Parenthesized forms are unaffected.
Note on the tree-sitter parser
#20990 added PEP 758 support to the tree-sitter parser, and python/extractor/tests/parser/exceptions_new.expected records the correct AST (both names Load, name: None). That parser is not the default, so the fix did not reach analysis. The two parsers currently disagree on this input, which the unsuffixed parser tests are meant to prevent.
Fix
PR #22386 proposes checking the separator token, so , builds a tuple of exception types and only as binds an alias. With it:
- the default parser reproduces the existing
exceptions_new.expectedbyte for byte; - of the 37 files in
tests/parser/, only the two containing PEP 758 syntax change at all; - patching the fix into the 2.26.3 bundle makes the false positive above disappear, while a genuinely unused import in the same file is still correctly reported.
There is a trade-off worth calling out: Python 2 source of the form except ValueError, e: that happens to parse under the Python 3 grammar will now be read as a tuple of types rather than an alias binding. The tree-sitter parser already made that choice, so this aligns the two.
Investigated and written with assistance from Claude Code; the reproduction, the database probes, and the test results above were all executed against the real 2.26.3 bundle rather than inferred.
Activity
jketema commentedon Aug 20, 2026
Thanks for the report. I'll ask the engineering team to have a look at your PR (this may take a few days).
aausch commentedon Aug 20, 2026
Follow-up on scope, after testing chains longer than two.
except A, B:except A, B, C:SyntaxErrorexcept A, B, C, D:SyntaxErrorexcept_clause: 'except' [test [(',' | 'as') test]]admits exactly one trailing test, so three or more types cannot parse at all, and the fallback insemmle/python/modules.py::Module.py_astrescues them. Only the two-type form is silently wrong — precisely because it is the only one the default parser accepts. So the scope in the original report is right, but for a more specific reason than I gave.One practical consequence: a file containing any three-type clause is immune to the two-type defect, because the failed parse sends the whole file to tree-sitter. That also makes the two cases untestable in a single file, which is why #22386 splits them.
Secondary observation, not addressed in the PR: the fallback costs a failed parse per affected file — roughly 541ms against 9ms for a neighbouring file in the same extraction. Making the grammar accept longer chains would avoid that, but it is a much larger change than this defect warrants, so I left it out. Happy to raise it separately if it is of interest.