Skip to content

Commit b6d7fa2

Browse files
chrisrink10Christopher Rink
andauthored
Add support for explicit exception cause chaining to the throw special form (#863)
Fixes #862 --------- Co-authored-by: Christopher Rink <[email protected]>
1 parent c258091 commit b6d7fa2

File tree

6 files changed

+108
-11
lines changed

6 files changed

+108
-11
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
### Added
99
* Added filename metadata to compiler exceptions (#844)
1010
* Added a compile-time warning for attempting to call a function with an unsupported number of arguments (#671)
11+
* Added support for explicit cause exception chaining to the `throw` special form (#862)
1112

1213
### Changed
1314
* Cause exceptions arising from compilation issues during macroexpansion will no longer be nested for each level of macroexpansion (#852)

docs/specialforms.rst

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -370,11 +370,21 @@ Primary Special Forms
370370
The Basilisp compiler makes attempts to verify whether a ``set!`` is legal at compile time, but there are cases which must be deferred to runtime due to the dynamic nature of the language.
371371
In particular, due to the non-lexical nature of dynamic Var bindings, it can be difficult to establish if a Var is thread-bound when it is ``set!``, so this check is deferred to runtime.
372372

373-
.. lpy:specialform:: (throw exc)
373+
.. lpy:specialform:: (throw exc cause?)
374374
375375
Throw the exception named by ``exc``.
376376
The semantics of ``throw`` are identical to those of Python's `raise <https://docs.python.org/3/reference/simple_stmts.html#the-raise-statement>`_ statement with exception.
377-
Unlike Python's ``raise``, an exception is always required and no explicit exception chaining is permitted (as by the ``from`` keyword in Python).
377+
Unlike Python's ``raise``, an exception is always required.
378+
A second optional cause exception may be provided after the exception to be thrown -- this is a direct Basilisp equivalent to ``from`` semantics to Python's ``raise`` statement.
379+
The cause may be ``nil`` to suppress cause chaining.
380+
381+
.. note::
382+
383+
Cause exceptions are stored in the ``__cause__`` attribute on thrown exceptions.
384+
Contrast this with the case where during the handling of an exception ``a`` , a second exception ``b`` is raised.
385+
Without explicit chaining, ``a`` would be stored in the ``__context__`` attribute of ``b``.
386+
Standard Python exception formatting language will show both cause and context exceptions, but describes each differently.
387+
For more details, see Python's documentation on `exception context <https://docs.python.org/3/library/exceptions.html#exception-context>`_.
378388

379389
.. lpy:specialform:: (try *exprs *catch-exprs finally?)
380390

src/basilisp/lang/compiler/analyzer.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3026,11 +3026,27 @@ def _set_bang_ast(form: ISeq, ctx: AnalyzerContext) -> SetBang:
30263026

30273027
def _throw_ast(form: ISeq, ctx: AnalyzerContext) -> Throw:
30283028
assert form.first == SpecialForm.THROW
3029+
nelems = count(form)
3030+
3031+
if nelems < 2 or nelems > 3:
3032+
raise ctx.AnalyzerException(
3033+
"throw forms must contain exactly 2 or 3 elements: (throw exc [cause])",
3034+
form=form,
3035+
)
3036+
30293037
with ctx.expr_pos():
30303038
exc = _analyze_form(runtime.nth(form, 1), ctx)
3039+
3040+
if nelems == 3:
3041+
with ctx.expr_pos():
3042+
cause = _analyze_form(runtime.nth(form, 2), ctx)
3043+
else:
3044+
cause = None
3045+
30313046
return Throw(
30323047
form=form,
30333048
exception=exc,
3049+
cause=cause,
30343050
env=ctx.get_node_env(pos=ctx.syntax_position),
30353051
)
30363052

src/basilisp/lang/compiler/generator.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2938,11 +2938,23 @@ def _throw_to_py_ast(ctx: GeneratorContext, node: Throw) -> GeneratedPyAST[ast.e
29382938
assert node.op == NodeOp.THROW
29392939

29402940
exc_ast = gen_py_ast(ctx, node.exception)
2941-
raise_body = ast.Raise(exc=exc_ast.node, cause=None)
2941+
2942+
cause: Optional[ast.AST]
2943+
cause_deps: Iterable[ast.AST]
2944+
if (
2945+
node.cause is not None
2946+
and (cause_ast := gen_py_ast(ctx, node.cause)) is not None
2947+
):
2948+
cause = cause_ast.node
2949+
cause_deps = cause_ast.dependencies
2950+
else:
2951+
cause, cause_deps = None, []
2952+
2953+
raise_body = ast.Raise(exc=exc_ast.node, cause=cause)
29422954

29432955
return GeneratedPyAST(
29442956
node=_noop_node(),
2945-
dependencies=list(chain(exc_ast.dependencies, [raise_body])),
2957+
dependencies=list(chain(exc_ast.dependencies, cause_deps, [raise_body])),
29462958
)
29472959

29482960

src/basilisp/lang/compiler/nodes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,7 @@ class SetBang(Node[SpecialForm]):
879879
class Throw(Node[SpecialForm]):
880880
form: SpecialForm
881881
exception: Node
882+
cause: Optional[Node]
882883
env: NodeEnv
883884
children: Sequence[kw.Keyword] = vec.v(EXCEPTION)
884885
op: NodeOp = NodeOp.THROW

tests/basilisp/compiler_test.py

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5134,15 +5134,72 @@ def test_syntax_quoting(test_ns: str, lcompile: CompileFn, resolver: reader.Reso
51345134
)
51355135

51365136

5137-
def test_throw(lcompile: CompileFn):
5138-
with pytest.raises(AttributeError):
5139-
lcompile("(throw (python/AttributeError))")
5137+
class TestThrow:
5138+
def test_throw_not_enough_args(self, lcompile: CompileFn):
5139+
with pytest.raises(compiler.CompilerException):
5140+
lcompile("(throw)")
51405141

5141-
with pytest.raises(TypeError):
5142-
lcompile("(throw (python/TypeError))")
5142+
def test_throw_too_many_args(self, lcompile: CompileFn):
5143+
with pytest.raises(compiler.CompilerException):
5144+
lcompile("(throw (python/ValueError) nil :a)")
5145+
5146+
def test_throw(self, lcompile: CompileFn):
5147+
with pytest.raises(AttributeError):
5148+
lcompile("(throw (python/AttributeError))")
5149+
5150+
with pytest.raises(TypeError):
5151+
lcompile("(throw (python/TypeError))")
51435152

5144-
with pytest.raises(ValueError):
5145-
lcompile("(throw (python/ValueError))")
5153+
with pytest.raises(ValueError):
5154+
lcompile("(throw (python/ValueError))")
5155+
5156+
def test_throw_chained(self, lcompile: CompileFn):
5157+
try:
5158+
lcompile(
5159+
"""
5160+
(try
5161+
(/ 1.0 0)
5162+
(catch python/ZeroDivisionError e
5163+
(throw (python/ValueError "lol") e)))
5164+
"""
5165+
)
5166+
except ValueError as e:
5167+
assert str(e) == "lol"
5168+
assert e.__suppress_context__ is True
5169+
assert isinstance(e.__cause__, ZeroDivisionError)
5170+
assert isinstance(e.__context__, ZeroDivisionError)
5171+
5172+
def test_throw_suppress_chain(self, lcompile: CompileFn):
5173+
try:
5174+
lcompile(
5175+
"""
5176+
(try
5177+
(/ 1.0 0)
5178+
(catch python/ZeroDivisionError e
5179+
(throw (python/ValueError "lol") nil)))
5180+
"""
5181+
)
5182+
except ValueError as e:
5183+
assert str(e) == "lol"
5184+
assert e.__suppress_context__ is True
5185+
assert e.__cause__ is None
5186+
assert isinstance(e.__context__, ZeroDivisionError)
5187+
5188+
def test_throw_context_only(self, lcompile: CompileFn):
5189+
try:
5190+
lcompile(
5191+
"""
5192+
(try
5193+
(/ 1.0 0)
5194+
(catch python/ZeroDivisionError e
5195+
(throw (python/ValueError "lol"))))
5196+
"""
5197+
)
5198+
except ValueError as e:
5199+
assert str(e) == "lol"
5200+
assert e.__suppress_context__ is False
5201+
assert e.__cause__ is None
5202+
assert isinstance(e.__context__, ZeroDivisionError)
51465203

51475204

51485205
class TestTryCatch:

0 commit comments

Comments
 (0)