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/python/parser/ast.py b/python/extractor/semmle/python/parser/ast.py index e1843131554a..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,7 +982,20 @@ 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) + # 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: + 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/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/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..5e7681b2436f --- /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`. 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. 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 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