Skip to content

Commit

Permalink
Merge pull request #2335 from Kodiologist/except-star
Browse files Browse the repository at this point in the history
Support `except*` in `try`
  • Loading branch information
Kodiologist authored Sep 9, 2022
2 parents 18a7380 + a9992b4 commit 3a4dc27
Show file tree
Hide file tree
Showing 6 changed files with 135 additions and 84 deletions.
1 change: 1 addition & 0 deletions NEWS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Other Breaking Changes
New Features
------------------------------
* Python 3.11 is now supported.
* `except*` (PEP 654) is now recognized in `try`.

Bug Fixes
------------------------------
Expand Down
3 changes: 2 additions & 1 deletion conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")

Expand All @@ -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 (
Expand Down
78 changes: 44 additions & 34 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1167,40 +1167,50 @@ base names, such that ``hy.core.macros.foo`` can be called as just ``foo``.

.. hy:function:: (try [#* body])
The ``try`` form is used to catch exceptions (``except``) and run cleanup
actions (``finally``).

:strong:`Examples`

::

(try
(error-prone-function)
(another-error-prone-function)
(except [ZeroDivisionError]
(print "Division by zero"))
(except [[IndexError KeyboardInterrupt]]
(print "Index error or Ctrl-C"))
(except [e ValueError]
(print "ValueError:" (repr e)))
(except [e [TabError PermissionError ReferenceError]]
(print "Some sort of error:" (repr e)))
(else
(print "No errors"))
(finally
(print "All done")))

The first argument of ``try`` is its body, which can contain one or more forms.
Then comes any number of ``except`` clauses, then optionally an ``else``
clause, then optionally a ``finally`` clause. If an exception is raised with a
matching ``except`` clause during the execution of the body, that ``except``
clause will be executed. If no exceptions are raised, the ``else`` clause is
executed. The ``finally`` clause will be executed last regardless of whether an
exception was raised.

The return value of ``try`` is the last form of the ``except`` clause that was
run, or the last form of ``else`` if no exception was raised, or the ``try``
body if there is no ``else`` clause.
``try`` compiles to a :py:keyword:`try` statement, which can catch
exceptions and run cleanup actions. It begins with any number of body forms.
Then follows any number of ``except`` or ``except*`` (:pep:`572`) forms,
which are expressions that begin with the symbol in question, followed by a
list of exception types, followed by more body forms. Finally there are an
optional ``else`` form and an optional ``finally`` form, which again are
expressions that begin with the symbol in question and then comprise body
forms. As in Python, at least one of ``except``, ``except*``, or ``finally``
is required; ``else`` is only allowed if at least one ``except`` or
``except*`` is provided; ``except*`` requires Python 3.11; and ``except``
and ``except*`` may not both be used in the same ``try``.

Here's an example of several of the allowed kinds of child forms::

(try
(error-prone-function)
(another-error-prone-function)
(except [ZeroDivisionError]
(print "Division by zero"))
(except [[IndexError KeyboardInterrupt]]
(print "Index error or Ctrl-C"))
(except [e ValueError]
(print "ValueError:" (repr e)))
(except [e [TabError PermissionError ReferenceError]]
(print "Some sort of error:" (repr e)))
(else
(print "No errors"))
(finally
(print "All done")))

Exception lists can be in any of several formats:

- ``[]`` to catch any subtype of ``Exception``, like Python's ``except:``
- ``[ETYPE]`` to catch only the single type ``ETYPE``, like Python's
```except ETYPE:``
- ``[[ETYPE1 ETYPE2 …]]`` to catch any of the named types, like Python's
``except ETYPE1, ETYPE2, …:``
- ``[VAR ETYPE]`` to catch ``ETYPE`` and bind it to ``VAR``, like Python's
``except ETYPE as VAR:``
- ``[VAR [ETYPE1 ETYPE2 …]]`` to catch any of the named types and bind it to
``VAR``, like Python's ``except ETYPE1, ETYPE2, … as VAR:``

The return value of ``try`` is the last form evaluated among the main body,
``except`` forms, ``except*`` forms, and ``else``.

.. hy:function:: (unpack-iterable)
.. hy:function:: (unpack-mapping)
Expand Down
103 changes: 56 additions & 47 deletions hy/core/result_macros.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
)
Expand All @@ -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())

Expand Down Expand Up @@ -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


# ------------------------------------------------
Expand Down
11 changes: 9 additions & 2 deletions tests/compilers/test_ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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():
Expand All @@ -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():
Expand Down
23 changes: 23 additions & 0 deletions tests/native_tests/py3_11_only_tests.hy
Original file line number Diff line number Diff line change
@@ -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")))

0 comments on commit 3a4dc27

Please sign in to comment.