Skip to content

Python: fix PEP 758 except A, B: extraction in the default parser - #22386

Open
aausch wants to merge 4 commits into
github:mainfrom
aausch:aausch/python-pep758-legacy-parser
Open

Python: fix PEP 758 except A, B: extraction in the default parser#22386
aausch wants to merge 4 commits into
github:mainfrom
aausch:aausch/python-pep758-legacy-parser

Conversation

@aausch

@aausch aausch commented Aug 19, 2026

Copy link
Copy Markdown

What

except A, B: (PEP 758, Python 3.14+) is extracted by the default parser using the Python 2 reading: B becomes an alias binding (Store) instead of a use (Load). Queries that reason about whether a name is used then misfire — py/unused-import reports the import of B as unused.

Why

blib2to3/Grammar.txt shares one rule between both readings:

except_clause: 'except' [test [(',' | 'as') test]]

and visit_except_clause never looked at the separator, so a fourth child was always bound as an alias:

if len(node.children) > 3:
    name = self.visit(node.children[3], STORE)

This patch decides from the separator token and the version being extracted:

clause Python 3 Python 2
except A as e: alias binding alias binding
except A, B: tuple of types, Load, no alias alias binding

So as behaves as before everywhere, and the comma form only changes meaning when we are not extracting Python 2. See Python 2 below.

Only the two-type form was affected. except A, B, C: already extracted correctly, and parenthesized forms were never affected.

Verification

  • Matches the existing expectation. With this change the default parser reproduces the checked-in python/extractor/tests/parser/exceptions_new.expected — the tree-sitter parser's expected AST for this exact syntax — byte for byte, locations and contexts included.
  • No collateral change. Dumping the default parser's AST for all 37 files in tests/parser/ before and after, only the two files containing PEP 758 syntax differ.
  • New parser test. tests/parser/exceptions_relaxed.py is unsuffixed, so the harness asserts the two parsers produce identical ASTs. It fails on main and passes with this change. pytest tests/test_parser.py → 37 passed.
  • New Python 2 extractor test. python/ql/test/2/extractor-tests/relaxed_except extracts with --lang=2 and pins, per handler, the types and whether the bound name is a definition — the property a query sees, not the shape of the AST. Removing the version gate makes it fail.
  • End to end. Patching this file into the codeql-bundle-v2.26.3 extractor and re-running Imports/UnusedImport.ql over the reproduction removes the false positive, while a genuinely unused import in the same file is still reported.
  • Whole suites, against a real CLI. With this branch's extractor patched into codeql 2.26.3: python/ql/test/query-tests/Imports all 17 passed, python/ql/test/2/extractor-tests 10 passed, and python/extractor pytest 116 passed. 2/extractor-tests/hidden fails, identically against the unpatched 2.26.3 extractor, so it does not come from this branch.
  • New query tests. python/ql/test/query-tests/Imports/unused/ gains coverage for two-, three- and four-type chains through the real extraction path. Reverting the extractor fix in a 2.26.3 bundle makes relaxed_except.py report Import of 'Beta' is not used. and the test fail.

Chains longer than two

Worth recording, because the two cases behave differently and the difference is not obvious.

clause default parser result today
except A, B: parses (as Python 2) wrong AST reaches the queries
except A, B, C: SyntaxError Module.py_ast falls back to tree-sitter, AST is 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. The fallback in
semmle/python/modules.py then rescues them. Only the two-type form is silently
wrong, precisely because it is the only one the default parser accepts.

This has a consequence for testing that cost me a first attempt: the two cases
cannot share a file. Any three-type clause fails the default parser, sends the
whole file to tree-sitter, and masks the two-type behaviour completely. So the
query tests are split into relaxed_except.py and relaxed_except_long.py,
with a comment in each explaining why. For the same reason each name is used in
exactly one clause — a name that also appears in a parenthesized clause is a use
regardless, and hides the defect.

I have deliberately not changed the grammar to accept longer chains. The
fallback already yields correct results, and a Grammar.txt change is a much
larger and riskier diff than the defect warrants. It may still be worth doing:
the fallback costs a failed parse per affected file, which measured at roughly
541ms versus 9ms for a neighbouring file in the same run. Happy to open that
separately if you would like it.

Python 2

except ValueError, e: is the canonical Python 2 idiom and it parses under the Python 3 grammar too, so the input is genuinely ambiguous and the grammar rule cannot separate the two readings. The default parser is version-agnostic and is tried first, which means it also handles Python 2 extraction (CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION=2, --lang=2).

The first version of this branch read the comma form as a tuple unconditionally, which broke that case: e flipped from a Store to a Load of an undefined name and the exception stopped being bound at all. Thanks to @redsun82 for catching it. visit_except_clause now consults get_analysis_major_version(), so Python 2 extraction keeps the alias binding it had before this branch, byte for byte, and only Python 3 extraction gets the PEP 758 reading.

Testing that through extraction needed one fix first. populator.main honours --lang by calling update_analysis_version, which rebinds a global in the process that parses the options — but extraction runs in an ExtractorPool, and on macOS those workers are spawned rather than forked, so they re-read CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION and saw the default of 3. --lang=2 therefore meant Python 2 on Linux and Python 3 on macOS. The flag now sets the environment variable as well, so it means the same thing on both. Real Python 2 extraction was never affected: the CodeQL action sets that variable itself and worker processes inherit it.

Chains of three or more need no gate. They are not valid Python 2, and the default grammar rejects them regardless of version, so Module.py_ast falls back to tree-sitter — see Chains longer than two.

Fixes #22387


Investigated with assistance from Claude Code. Every result quoted above was executed against the real 2.26.3 bundle and the repo's own test harness, not inferred.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes default Python parser extraction of PEP 758 exception lists, aligning it with the tree-sitter parser.

Changes:

  • Distinguishes as aliases from comma-separated exception types.
  • Adds parser parity regression coverage.
  • Documents the corrected extraction behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
python/extractor/semmle/python/parser/ast.py Extracts except A, B: as a load-context tuple.
python/extractor/tests/parser/exceptions_relaxed.py Tests exception-list and alias variants across parsers.
python/ql/lib/change-notes/2026-08-19-legacy-parser-relaxed-except.md Records the parser fix and query impact.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

aausch and others added 2 commits August 20, 2026 11:10
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
(github#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) <noreply@anthropic.com>
…ngth

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) <noreply@anthropic.com>
@aausch
aausch force-pushed the aausch/python-pep758-legacy-parser branch from 03c2f1e to 896d8d7 Compare August 20, 2026 09:10
@tausbn tausbn self-assigned this Aug 25, 2026
Comment on lines +986 to +993
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this might actually be a problem for Python 2, which is still officially supported 😅

The default (blib2to3) parser is the primary one (modules.py tries it first, tree-sitter is only the fallback), and this branch is version-agnostic, so it also kicks in when we extract in Python 2 mode (CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION=2). There except Exception, e: really is the alias binding, so with this change e flips from a Store to a Load of an undefined name, and the exception stops being bound. That's the canonical py2 idiom, so it's not exactly a rare construct.

Could we gate the tuple reading on the version? Something like keeping the old alias branch when get_analysis_major_version() == 2, and only building the tuple otherwise. The 3+ types case isn't affected (three unparenthesized types aren't valid py2 anyway, so the tree-sitter fallback is fine there).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — and it was worse than a mislabelled AST: in Python 2 mode the binding disappeared entirely, so e became a Load of an undefined name.

Gated as you suggested in 117fc6b. as binds an alias in every version; a comma binds an alias when get_analysis_major_version() == 2 and builds the tuple otherwise. Three or more types are unaffected — not valid py2, and the default grammar rejects them regardless of version, so the tree-sitter fallback covers them.

The file-driven parser tests can't express this, since they run at the default analysis version with no per-fixture override. So python/extractor/tests/test_except_clause.py drives parser.parse directly with the version flipped and pins all four combinations: comma and as, py2 and py3, plus the parenthesized form that must bind no alias in either. Removing the gate fails the py2 case, and pytest tests/test_parser.py still passes 37.

Happy to add a --lang=2 query test under Imports/unused/ as well if you would rather see it through the real extraction path — I left it out because the unit test pins the exact decision site.

I have also rewritten the "Trade-off" section of the description, which described the behaviour this replaces.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A more appropriate solution would be to add an extractor test in python/ql/test/2/extractor-tests. I think that's preferable to a bespoke unit test.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — done in 7d9ae21, and the unit test is gone. python/ql/test/2/extractor-tests/relaxed_except extracts with --lang=2 and pins, per handler, the types and whether the bound name is a definition, so it asserts what a query actually sees rather than the AST shape. Removing the version gate makes it fail.

Getting there turned up something you may want to know independently of this PR: --lang did not reach the worker processes. populator.main honours it by calling update_analysis_version, but that rebinds a global in the process that parses the options, while extraction happens in an ExtractorPool — and on macOS those workers are spawned, not forked, so they re-read CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION and saw the default of 3. So --lang=2 meant Python 2 on Linux and Python 3 on macOS. My first version of this test passed under CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION=2 and failed under --lang=2 on the same machine, which is what gave it away. The commit also sets the environment variable alongside the global, so the flag means the same thing on both platforms. Real Python 2 extraction was never affected — the action sets that variable itself and children inherit it. Happy to split that into its own PR if you would rather it not ride along.

I also bumped the extractor version, which the first commit should have done.

Verified with codeql 2.26.3, this branch's extractor patched into it:

  • python/ql/test/2/extractor-tests — 10 passed. hidden/test.ql fails, but it fails identically against the unpatched 2.26.3 extractor (extra | .hidden/inner | and | folder | rows), so it is not from this branch.
  • python/ql/test/query-tests/Imports — all 17 passed, which also re-confirms the relaxed_except*.py query tests from the earlier commit.
  • python/extractor pytest — 116 passed, including the 37 parser tests.

aausch and others added 2 commits August 25, 2026 15:52
The previous commit read a comma-separated fourth child as a tuple of exception
types unconditionally. That is right for Python 3, but the default parser is
version-agnostic and also runs when extracting Python 2
(`CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION=2`, `--lang=2`), where
`except Exception, e:` really is the alias binding and the canonical idiom. In
that mode the change flipped `e` from a `Store` to a `Load` of an undefined
name and dropped the binding altogether.

So the separator token alone is not enough to decide: `as` binds an alias in
every version, a comma binds an alias under Python 2 and builds a tuple
otherwise. Chains of three or more are unaffected either way -- they are not
valid Python 2, and the default grammar rejects them, so `Module.py_ast` falls
back to tree-sitter.

The file-driven parser tests cannot express this; they run at the default
analysis version and there is no per-fixture way to change it. So
`tests/test_except_clause.py` drives `parser.parse` directly with the version
flipped, and pins all four combinations -- comma and `as`, Python 2 and 3, plus
the parenthesized form that must bind no alias in either. Removing the version
gate fails the Python 2 case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…it test

Replaces `tests/test_except_clause.py` with an extractor test, as suggested in
review. `python/ql/test/2/extractor-tests/relaxed_except` extracts a Python 2
file with `--lang=2` and pins, per handler, the types and whether the bound name
is a definition -- so it asserts the consequence a query sees, not the shape of
the AST. Removing the version gate from `visit_except_clause` makes it fail.

Doing it that way needed one extractor fix first. `populator.main` honours
`--lang` by calling `update_analysis_version`, but that only rebinds a global in
the process that parses the options; the extraction itself runs in an
`ExtractorPool`, and on macOS those workers are spawned rather than forked, so
they re-read `CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION` from the environment and
saw the default of 3. `--lang=2` therefore meant Python 2 on Linux and Python 3
on macOS. Setting the variable as well as the global makes the flag mean the
same thing on both, which is what lets the new test pin the Python 2 reading
anywhere. Real Python 2 extraction was never affected: the CodeQL action sets
that variable itself, and children inherit it.

Bumps the extractor version, which the fix in the first commit should have done.

Verified with codeql 2.26.3 and this branch's extractor patched into it:
`python/ql/test/2/extractor-tests` 10 passed (`hidden` fails identically on the
unpatched extractor, so it is not from this branch), and the py3 side is
unchanged -- `python/ql/test/query-tests/Imports` all 17 passed, which also
confirms the `relaxed_except*.py` query tests added earlier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@tausbn tausbn left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me. I think the explicit --lang=2 in the extractor options is not needed in this case, since the tests in that directory are already run with that flag, but it's harmless, so there's no need to change that. Good catch on the macOS spawn issue!

I'll run some more internal checks before merging, but otherwise I think this should be good to go. Thanks for the contribution! ❤️

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

4 participants