Repository: hylang/hy

Issue:
Support Python 3.11's `except*`
This feature is described in [PEP 654](https://peps.python.org/pep-0654/).


Discussion:

Resolving patch:
diff --git a/conftest.py b/conftest.py
--- a/conftest.py
+++ b/conftest.py
@@ -8,7 +8,7 @@
 import pytest
 
 import hy
-from hy._compat import PY3_8, PY3_10
+from hy._compat import PY3_8, PY3_10, PY3_11
 
 NATIVE_TESTS = os.path.join("", "tests", "native_tests", "")
 
@@ -21,6 +21,7 @@ def pytest_ignore_collect(path, config):
         (sys.version_info < (3, 8), "sub_py3_7_only"),
         (PY3_8, "py3_8_only"),
         (PY3_10, "py3_10_only"),
+        (PY3_11, "py3_11_only"),
     ]
 
     return (
diff --git a/hy/core/result_macros.py b/hy/core/result_macros.py
--- a/hy/core/result_macros.py
+++ b/hy/core/result_macros.py
@@ -15,6 +15,7 @@
 
 from funcparserlib.parser import finished, forward_decl, many, maybe, oneplus, some
 
+from hy._compat import PY3_11
 from hy.compiler import Result, asty, hy_eval, mkexpr
 from hy.errors import HyEvalError, HyInternalError, HyTypeError
 from hy.macros import pattern_macro, require, require_reader
@@ -1267,10 +1268,10 @@ def compile_raise_expression(compiler, expr, root, exc, cause):
 @pattern_macro(
     "try",
     [
-        many(notpexpr("except", "else", "finally")),
+        many(notpexpr("except", "except*", "else", "finally")),
         many(
             pexpr(
-                sym("except"),
+                keepsym("except") | keepsym("except*"),
                 brackets() | brackets(FORM) | brackets(SYM, FORM),
                 many(FORM),
             )
@@ -1286,9 +1287,57 @@ def compile_try_expression(compiler, expr, root, body, catchers, orelse, finalbo
 
     handler_results = Result()
     handlers = []
+    except_syms_seen = set()
     for catcher in catchers:
-        handler_results += compile_catch_expression(
-            compiler, catcher, return_var, *catcher
+        # exceptions catch should be either:
+        # [[list of exceptions]]
+        # or
+        # [variable [list of exceptions]]
+        # or
+        # [variable exception]
+        # or
+        # [exception]
+        # or
+        # []
+        except_sym, exceptions, ebody = catcher
+        if not PY3_11 and except_sym == Symbol("except*"):
+            hy_compiler._syntax_error(except_sym, "`{}` requires Python 3.11 or later")
+        except_syms_seen.add(str(except_sym))
+        if len(except_syms_seen) > 1:
+            raise compiler._syntax_error(
+                except_sym, "cannot have both `except` and `except*` on the same `try`"
+            )
+
+        name = None
+        if len(exceptions) == 2:
+            name = mangle(compiler._nonconst(exceptions[0]))
+
+        exceptions_list = exceptions[-1] if exceptions else List()
+        if isinstance(exceptions_list, List):
+            if len(exceptions_list):
+                # [FooBar BarFoo] → catch Foobar and BarFoo exceptions
+                elts, types, _ = compiler._compile_collect(exceptions_list)
+                types += asty.Tuple(exceptions_list, elts=elts, ctx=ast.Load())
+            else:
+                # [] → all exceptions caught
+                types = Result()
+        else:
+            types = compiler.compile(exceptions_list)
+
+        # Create a "fake" scope for the exception variable.
+        # See: https://docs.python.org/3/reference/compound_stmts.html#the-try-statement
+        with compiler.scope.create(ScopeLet) as scope:
+            if name:
+                scope.add(name, name)
+            ebody = compiler._compile_branch(ebody)
+        ebody += asty.Assign(catcher, targets=[return_var], value=ebody.force_expr)
+        ebody += ebody.expr_as_stmt()
+
+        handler_results += types + asty.ExceptHandler(
+            catcher,
+            type=types.expr,
+            name=name,
+            body=ebody.stmts or [asty.Pass(catcher)],
         )
         handlers.append(handler_results.stmts.pop())
 
@@ -1327,50 +1376,10 @@ def compile_try_expression(compiler, expr, root, body, catchers, orelse, finalbo
     )
     body = body.stmts or [asty.Pass(expr)]
 
-    x = asty.Try(expr, body=body, handlers=handlers, orelse=orelse, finalbody=finalbody)
-    return handler_results + x + returnable
-
-
-def compile_catch_expression(compiler, expr, var, exceptions, body):
-    # exceptions catch should be either:
-    # [[list of exceptions]]
-    # or
-    # [variable [list of exceptions]]
-    # or
-    # [variable exception]
-    # or
-    # [exception]
-    # or
-    # []
-
-    name = None
-    if len(exceptions) == 2:
-        name = mangle(compiler._nonconst(exceptions[0]))
-
-    exceptions_list = exceptions[-1] if exceptions else List()
-    if isinstance(exceptions_list, List):
-        if len(exceptions_list):
-            # [FooBar BarFoo] → catch Foobar and BarFoo exceptions
-            elts, types, _ = compiler._compile_collect(exceptions_list)
-            types += asty.Tuple(exceptions_list, elts=elts, ctx=ast.Load())
-        else:
-            # [] → all exceptions caught
-            types = Result()
-    else:
-        types = compiler.compile(exceptions_list)
-
-    # Create a "fake" scope for the exception variable.
-    # See: https://docs.python.org/3/reference/compound_stmts.html#the-try-statement
-    with compiler.scope.create(ScopeLet) as scope:
-        if name:
-            scope.add(name, name)
-        body = compiler._compile_branch(body)
-    body += asty.Assign(expr, targets=[var], value=body.force_expr)
-    body += body.expr_as_stmt()
-
-    return types + asty.ExceptHandler(
-        expr, type=types.expr, name=name, body=body.stmts or [asty.Pass(expr)]
+    x = (asty.TryStar if "except*" in except_syms_seen else asty.Try)(
+        expr, body=body, handlers=handlers, orelse=orelse, finalbody=finalbody
     )
+    return handler_results + x + returnable
 
 
 # ------------------------------------------------


Test patch:
diff --git a/tests/compilers/test_ast.py b/tests/compilers/test_ast.py
--- a/tests/compilers/test_ast.py
+++ b/tests/compilers/test_ast.py
@@ -4,6 +4,7 @@
 
 import pytest
 
+from hy._compat import PY3_11
 from hy.compiler import hy_compile, hy_eval
 from hy.errors import HyError, HyLanguageError
 from hy.reader import read_many
@@ -17,8 +18,10 @@ def _ast_spotcheck(arg, root, secondary):
     assert getattr(root, arg) == getattr(secondary, arg)
 
 
-def can_compile(expr, import_stdlib=False):
-    return hy_compile(read_many(expr), __name__, import_stdlib=import_stdlib)
+def can_compile(expr, import_stdlib=False, iff=True):
+    return (hy_compile(read_many(expr), __name__, import_stdlib=import_stdlib)
+        if iff
+        else cant_compile(expr))
 
 
 def can_eval(expr):
@@ -128,6 +131,8 @@ def test_ast_good_try():
     can_compile("(try 1 (except [x]) (except [y]) (finally 1))")
     can_compile("(try 1 (except []) (else 1) (finally 1))")
     can_compile("(try 1 (except [x]) (except [y]) (else 1) (finally 1))")
+    can_compile(iff = PY3_11, expr = "(try 1 (except* [x]))")
+    can_compile(iff = PY3_11, expr = "(try 1 (except* [x]) (else 1) (finally 1))")
 
 
 def test_ast_bad_try():
@@ -142,6 +147,8 @@ def test_ast_bad_try():
     cant_compile("(try 1 (else 1) (except []))")
     cant_compile("(try 1 (finally 1) (except []))")
     cant_compile("(try 1 (except []) (finally 1) (else 1))")
+    cant_compile("(try 1 (except* [x]) (except [x]))")
+    cant_compile("(try 1 (except [x]) (except* [x]))")
 
 
 def test_ast_good_except():
diff --git a/tests/native_tests/py3_11_only_tests.hy b/tests/native_tests/py3_11_only_tests.hy
new file mode 100644
--- /dev/null
+++ b/tests/native_tests/py3_11_only_tests.hy
@@ -0,0 +1,23 @@
+(defn test-except* []
+  (setv got "")
+
+  (setv return-value (try
+    (raise (ExceptionGroup "meep" [(KeyError) (ValueError)]))
+    (except* [KeyError]
+      (+= got "k")
+      "r1")
+    (except* [IndexError]
+      (+= got "i")
+      "r2")
+    (except* [ValueError]
+      (+= got "v")
+      "r3")
+    (else
+      (+= got "e")
+      "r4")
+    (finally
+      (+= got "f")
+      "r5")))
+
+  (assert (= got "kvf"))
+  (assert (= return-value "r3")))