Skip to content

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

Closed

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.

Activity

  1. jketema commented on Aug 20, 2026

    @jketema
    Contributor

    Thanks for the report. I'll ask the engineering team to have a look at your PR (this may take a few days).

  2. aausch commented on Aug 20, 2026

    @aausch
    ContributorAuthor

    Follow-up on scope, after testing chains longer than two.

    clause default parser result today
    except A, B: parses (as Python 2) wrong AST reaches the queries
    except A, B, C: SyntaxError falls back to tree-sitter, correct
    except A, B, C, D: SyntaxError same, correct

    except_clause: 'except' [test [(',' | 'as') test]] admits exactly one trailing test, so three or more types cannot parse at all, and the fallback in semmle/python/modules.py::Module.py_ast rescues 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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