From 66573bb5951d60ad88d20fce4989345ee5d1aeaf Mon Sep 17 00:00:00 2001 From: Alex Ausch Date: Wed, 19 Aug 2026 17:11:54 +0200 Subject: [PATCH 1/4] 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/4] 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 From 117fc6b310d87714c5cbc3dace0aa72490cec279 Mon Sep 17 00:00:00 2001 From: Alex Ausch Date: Tue, 25 Aug 2026 15:52:57 +0200 Subject: [PATCH 3/4] Python: keep the Python 2 reading of `except A, e:` 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) --- python/extractor/semmle/python/parser/ast.py | 15 ++-- python/extractor/tests/test_except_clause.py | 71 +++++++++++++++++++ ...2026-08-19-legacy-parser-relaxed-except.md | 2 +- 3 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 python/extractor/tests/test_except_clause.py diff --git a/python/extractor/semmle/python/parser/ast.py b/python/extractor/semmle/python/parser/ast.py index 0712bf08ae45..9a1cea1ae62e 100644 --- a/python/extractor/semmle/python/parser/ast.py +++ b/python/extractor/semmle/python/parser/ast.py @@ -1,6 +1,7 @@ from blib2to3.pgen2 import token from ast import literal_eval from semmle.python import ast +from semmle.util import get_analysis_major_version from blib2to3.pgen2.parse import ParseError import sys @@ -981,13 +982,17 @@ def visit_except_clause(self, node): if len(node.children) > 1: type = self.visit(node.children[1], LOAD) if len(node.children) > 3: - if is_token(node.children[2], "as"): + # The grammar rule `'except' [test [(',' | 'as') test]]` is shared + # between two incompatible readings of a fourth child, so the + # separator token and the analysis version together decide: + # `except A as e:` binds an alias, in every version; + # `except A, e:` binds an alias when extracting Python 2, where + # that is the canonical idiom; + # `except A, B:` is an unparenthesized tuple of exception types + # otherwise -- PEP 758, Python 3.14+. + if is_token(node.children[2], "as") or get_analysis_major_version() == 2: 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) diff --git a/python/extractor/tests/test_except_clause.py b/python/extractor/tests/test_except_clause.py new file mode 100644 index 000000000000..d09ae382b9dd --- /dev/null +++ b/python/extractor/tests/test_except_clause.py @@ -0,0 +1,71 @@ +import unittest +from contextlib import contextmanager + +from semmle import util +from semmle.python import ast +from semmle.python import parser +from semmle.python.parser.dump_ast import StdoutLogger +from semmle.python.parser.tokenizer import Tokenizer + + +@contextmanager +def analysis_version(version): + 'Extract as if `CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION` were `version`.' + previous = util.get_analysis_version() + util.update_analysis_version(version) + try: + yield + finally: + util.update_analysis_version(previous) + + +class ExceptClauseTest(unittest.TestCase): + '''`except_clause: 'except' [test [(',' | 'as') test]]` is one grammar rule + covering two incompatible readings of `except A, B:` -- a Python 2 alias + binding and a PEP 758 tuple of exception types. Which one the default parser + picks depends on the version being extracted, so these tests pin both. + ''' + + def handler(self, source): + 'The first `except` handler of the first statement of `source`.' + with StdoutLogger() as logger: + module = parser.parse(Tokenizer(source).tokens(), logger) + return module.body[0].handlers[0] + + def test_comma_is_a_tuple_of_types_in_python_3(self): + with analysis_version("3.11"): + handler = self.handler("try:\n a\nexcept b, c:\n d\n") + self.assertIsNone(handler.name) + self.assertIsInstance(handler.type, ast.Tuple) + self.assertIsInstance(handler.type.ctx, ast.Load) + self.assertEqual(["b", "c"], [elt.id for elt in handler.type.elts]) + for elt in handler.type.elts: + self.assertIsInstance(elt.ctx, ast.Load) + + def test_comma_is_an_alias_binding_in_python_2(self): + with analysis_version("2.7.18"): + handler = self.handler("try:\n a\nexcept b, c:\n d\n") + self.assertIsInstance(handler.type, ast.Name) + self.assertEqual("b", handler.type.id) + self.assertIsInstance(handler.type.ctx, ast.Load) + self.assertIsInstance(handler.name, ast.Name) + self.assertEqual("c", handler.name.id) + self.assertIsInstance(handler.name.ctx, ast.Store) + + def test_as_is_an_alias_binding_in_both_versions(self): + for version in ("3.11", "2.7.18"): + with analysis_version(version): + handler = self.handler("try:\n a\nexcept b as c:\n d\n") + self.assertIsInstance(handler.type, ast.Name, version) + self.assertEqual("b", handler.type.id, version) + self.assertIsInstance(handler.name, ast.Name, version) + self.assertEqual("c", handler.name.id, version) + self.assertIsInstance(handler.name.ctx, ast.Store, version) + + def test_parenthesised_types_bind_no_alias_in_either_version(self): + for version in ("3.11", "2.7.18"): + with analysis_version(version): + handler = self.handler("try:\n a\nexcept (b, c):\n d\n") + self.assertIsNone(handler.name, version) + self.assertIsInstance(handler.type, ast.Tuple, version) + self.assertEqual(["b", "c"], [elt.id for elt in handler.type.elts], version) 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 index bd4bd599f568..5e7681b2436f 100644 --- 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 @@ -1,4 +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`. +* 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`. When extracting Python 2 (`--lang=2`), `except A, e:` continues to bind `e` as an alias, since that is what the syntax means in that version. From 7d9ae21aae92a8e47ad78f3db878f4ae5464a279 Mon Sep 17 00:00:00 2001 From: Alex Ausch Date: Tue, 25 Aug 2026 16:32:01 +0200 Subject: [PATCH 4/4] Python: test the Python 2 except reading through extraction, not a unit 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) --- python/extractor/semmle/populator.py | 5 ++ python/extractor/semmle/util.py | 2 +- python/extractor/tests/test_except_clause.py | 71 ------------------- .../2/extractor-tests/relaxed_except/options | 1 + .../relaxed_except/relaxed_except.expected | 3 + .../relaxed_except/relaxed_except.ql | 26 +++++++ .../2/extractor-tests/relaxed_except/test.py | 19 +++++ 7 files changed, 55 insertions(+), 72 deletions(-) delete mode 100644 python/extractor/tests/test_except_clause.py create mode 100644 python/ql/test/2/extractor-tests/relaxed_except/options create mode 100644 python/ql/test/2/extractor-tests/relaxed_except/relaxed_except.expected create mode 100644 python/ql/test/2/extractor-tests/relaxed_except/relaxed_except.ql create mode 100644 python/ql/test/2/extractor-tests/relaxed_except/test.py diff --git a/python/extractor/semmle/populator.py b/python/extractor/semmle/populator.py index a1be196ffaf6..603a7e2ddaca 100644 --- a/python/extractor/semmle/populator.py +++ b/python/extractor/semmle/populator.py @@ -65,6 +65,11 @@ def main(sys_path = sys.path[:]): if options.language_version: last_version = options.language_version[-1] update_analysis_version(last_version) + # Worker processes are spawned rather than forked on macOS, so they do + # not inherit the value set above; they re-read it from the environment + # as this module did on import. Set it there too, or `--lang` would take + # effect in this process only, and on one platform only. + os.environ["CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION"] = last_version found_py2 = False if get_analysis_major_version() == 2 and options.extract_stdlib: diff --git a/python/extractor/semmle/util.py b/python/extractor/semmle/util.py index 00651ace8314..977d47c69dca 100644 --- a/python/extractor/semmle/util.py +++ b/python/extractor/semmle/util.py @@ -10,7 +10,7 @@ #Semantic version of extractor. #Update this if any changes are made -VERSION = "7.1.8" +VERSION = "7.1.9" PY_EXTENSIONS = ".py", ".pyw" diff --git a/python/extractor/tests/test_except_clause.py b/python/extractor/tests/test_except_clause.py deleted file mode 100644 index d09ae382b9dd..000000000000 --- a/python/extractor/tests/test_except_clause.py +++ /dev/null @@ -1,71 +0,0 @@ -import unittest -from contextlib import contextmanager - -from semmle import util -from semmle.python import ast -from semmle.python import parser -from semmle.python.parser.dump_ast import StdoutLogger -from semmle.python.parser.tokenizer import Tokenizer - - -@contextmanager -def analysis_version(version): - 'Extract as if `CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION` were `version`.' - previous = util.get_analysis_version() - util.update_analysis_version(version) - try: - yield - finally: - util.update_analysis_version(previous) - - -class ExceptClauseTest(unittest.TestCase): - '''`except_clause: 'except' [test [(',' | 'as') test]]` is one grammar rule - covering two incompatible readings of `except A, B:` -- a Python 2 alias - binding and a PEP 758 tuple of exception types. Which one the default parser - picks depends on the version being extracted, so these tests pin both. - ''' - - def handler(self, source): - 'The first `except` handler of the first statement of `source`.' - with StdoutLogger() as logger: - module = parser.parse(Tokenizer(source).tokens(), logger) - return module.body[0].handlers[0] - - def test_comma_is_a_tuple_of_types_in_python_3(self): - with analysis_version("3.11"): - handler = self.handler("try:\n a\nexcept b, c:\n d\n") - self.assertIsNone(handler.name) - self.assertIsInstance(handler.type, ast.Tuple) - self.assertIsInstance(handler.type.ctx, ast.Load) - self.assertEqual(["b", "c"], [elt.id for elt in handler.type.elts]) - for elt in handler.type.elts: - self.assertIsInstance(elt.ctx, ast.Load) - - def test_comma_is_an_alias_binding_in_python_2(self): - with analysis_version("2.7.18"): - handler = self.handler("try:\n a\nexcept b, c:\n d\n") - self.assertIsInstance(handler.type, ast.Name) - self.assertEqual("b", handler.type.id) - self.assertIsInstance(handler.type.ctx, ast.Load) - self.assertIsInstance(handler.name, ast.Name) - self.assertEqual("c", handler.name.id) - self.assertIsInstance(handler.name.ctx, ast.Store) - - def test_as_is_an_alias_binding_in_both_versions(self): - for version in ("3.11", "2.7.18"): - with analysis_version(version): - handler = self.handler("try:\n a\nexcept b as c:\n d\n") - self.assertIsInstance(handler.type, ast.Name, version) - self.assertEqual("b", handler.type.id, version) - self.assertIsInstance(handler.name, ast.Name, version) - self.assertEqual("c", handler.name.id, version) - self.assertIsInstance(handler.name.ctx, ast.Store, version) - - def test_parenthesised_types_bind_no_alias_in_either_version(self): - for version in ("3.11", "2.7.18"): - with analysis_version(version): - handler = self.handler("try:\n a\nexcept (b, c):\n d\n") - self.assertIsNone(handler.name, version) - self.assertIsInstance(handler.type, ast.Tuple, version) - self.assertEqual(["b", "c"], [elt.id for elt in handler.type.elts], version) diff --git a/python/ql/test/2/extractor-tests/relaxed_except/options b/python/ql/test/2/extractor-tests/relaxed_except/options new file mode 100644 index 000000000000..b61a8c65a925 --- /dev/null +++ b/python/ql/test/2/extractor-tests/relaxed_except/options @@ -0,0 +1 @@ +semmle-extractor-options: --lang=2 diff --git a/python/ql/test/2/extractor-tests/relaxed_except/relaxed_except.expected b/python/ql/test/2/extractor-tests/relaxed_except/relaxed_except.expected new file mode 100644 index 000000000000..06aeec986903 --- /dev/null +++ b/python/ql/test/2/extractor-tests/relaxed_except/relaxed_except.expected @@ -0,0 +1,3 @@ +| 6 | ValueError | err (definition) | +| 12 | ValueError | other (definition) | +| 18 | ValueError, TypeError | none | diff --git a/python/ql/test/2/extractor-tests/relaxed_except/relaxed_except.ql b/python/ql/test/2/extractor-tests/relaxed_except/relaxed_except.ql new file mode 100644 index 000000000000..55369e89f836 --- /dev/null +++ b/python/ql/test/2/extractor-tests/relaxed_except/relaxed_except.ql @@ -0,0 +1,26 @@ +/** + * The types of each `except` clause, and the name it binds. In Python 2 the + * comma form binds a name and has a single type; reading it as a PEP 758 tuple + * instead would give two types and no name. + */ + +import python + +from ExceptStmt handler, string types, string name +where + types = + concat(Expr type | + type = handler.getType() + | + type.toString(), ", " order by type.getLocation().getStartColumn() + ) and + ( + exists(Name bound | bound = handler.getName() | + bound.isDefinition() and name = bound.getId() + " (definition)" + or + not bound.isDefinition() and name = bound.getId() + " (use)" + ) + or + not exists(handler.getName()) and name = "none" + ) +select handler.getLocation().getStartLine(), types, name diff --git a/python/ql/test/2/extractor-tests/relaxed_except/test.py b/python/ql/test/2/extractor-tests/relaxed_except/test.py new file mode 100644 index 000000000000..f1fbade32f99 --- /dev/null +++ b/python/ql/test/2/extractor-tests/relaxed_except/test.py @@ -0,0 +1,19 @@ +# When extracting Python 2, `except A, e:` binds `e`. It is not a PEP 758 +# unparenthesized tuple of exception types, which is what the same syntax means +# from Python 3.14 on. +try: + unlikely() +except ValueError, err: + print err + +# `as` means the same thing in every version. +try: + unlikely() +except ValueError as other: + print other + +# A parenthesized tuple is several types, and binds nothing. +try: + unlikely() +except (ValueError, TypeError): + pass