Skip to content

Python: PEP 758 except A, B: extracted as a Python 2 alias, causing py/unused-import false positives #22387

Description

@aausch

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):
    pass

unparenthesized.py:

from errs import Alpha, Beta


def f():
    try:
        pass
    except Alpha, Beta:
        raise

parenthesized.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, name

So 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.expected byte 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions