Skip to content

Commit b2b5739

Browse files
committed
add cause parameter to signal, error, cerror, warn
This allows, essentially, to perform `signal ... from ...` et al., like in `raise ... from ...`.
1 parent 63afa27 commit b2b5739

File tree

2 files changed

+83
-11
lines changed

2 files changed

+83
-11
lines changed

unpythonic/conditions.py

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,7 @@ class ControlError(Exception):
8686
when no handler handles the signal.
8787
"""
8888

89-
# TODO: now that signals have tracebacks (in Python 3.7+), "signal from" (like "raise from") would be nice.
90-
def signal(condition):
89+
def signal(condition, *, cause=None):
9190
"""Signal a condition.
9291
9392
Signaling a condition works similarly to raising an exception (pass an
@@ -120,6 +119,11 @@ def signal(condition):
120119
otherwise the same as `signal`, except it raises if `signal` would have
121120
returned normally.
122121
122+
The optional `cause` argument is as in `unpythonic.misc.raisef`.
123+
In other words, if we pretend for a moment that `signal` is a Python
124+
keyword, it essentially performs a `signal ... from ...`. The default
125+
`cause=None` performs a plain `signal ...`.
126+
123127
**Notes**
124128
125129
This condition system is implemented on top of exceptions. The magic trick
@@ -159,17 +163,21 @@ def accepts_arg(f):
159163
# special handling for the "class raised" case.
160164
# https://docs.python.org/3/reference/simple_stmts.html#the-raise-statement
161165
# https://stackoverflow.com/questions/19768515/is-there-a-difference-between-raising-exception-class-and-exception-instance/19768732
162-
def canonize(exc):
166+
def canonize(exc, err_reason):
167+
if exc is None:
168+
return None
163169
if isinstance(exc, BaseException): # "signal(SomeError())"
164170
return exc
165171
try:
166172
if issubclass(exc, BaseException): # "signal(SomeError)"
167173
return exc() # instantiate with no args, like `raise` does
168174
except TypeError: # "issubclass() arg 1 must be a class"
169175
pass
170-
error(ControlError("Only exceptions and subclasses of Exception can be signaled; got {} with value '{}'.".format(type(condition), condition)))
176+
error(ControlError("Only exceptions and subclasses of Exception can {}; got {} with value '{}'.".format(err_reason, type(condition), condition)))
171177

172-
condition = canonize(condition)
178+
condition = canonize(condition, "be signaled")
179+
cause = canonize(cause, "act as the cause of another signal")
180+
condition.__cause__ = cause
173181

174182
# Embed a stack trace in the signal, like Python does for raised exceptions.
175183
# This only works on Python 3.7 and later, because we need to create a traceback object in pure Python code.
@@ -666,8 +674,9 @@ def call_with_restarts(f):
666674
return call_with_restarts
667675

668676
# Common Lisp standard error handling protocols, building on the `signal` function.
677+
# Pythonified to add the `cause` argument.
669678

670-
def error(condition):
679+
def error(condition, *, cause=None):
671680
"""Like `signal`, but raise `ControlError` if the condition is not handled.
672681
673682
Note **raise**, not **signal**. Keep in mind the original Common Lisp
@@ -678,17 +687,23 @@ def error(condition):
678687
679688
Note *handled* means that a handler must actually invoke a restart; a
680689
condition does not count as handled simply because a handler was triggered.
690+
691+
The optional `cause` argument is as in `unpythonic.misc.raisef`.
692+
In other words, if we pretend for a moment that `error` is a Python
693+
keyword, it essentially performs a `error ... from ...`. The default
694+
`cause=None` performs a plain `error ...`.
681695
"""
682-
signal(condition)
696+
signal(condition, cause=cause)
683697
# TODO: If we want to support the debugger at some point in the future,
684698
# TODO: this is the appropriate point to ask the user what to do,
685699
# TODO: before the call stack unwinds.
686700
#
687701
# TODO: Do we want to give one last chance to handle the ControlError?
688702
# TODO: And do we want to raise ControlError, or the original condition?
703+
condition.__cause__ = cause # chain the causes, since we'll add a new one next.
689704
raise ControlError("Unhandled error condition") from condition
690705

691-
def cerror(condition):
706+
def cerror(condition, *, cause=None):
692707
"""Like `error`, but allow a handler to instruct the caller to ignore the error.
693708
694709
`cerror` internally establishes a restart named `proceed`, which can be
@@ -699,6 +714,11 @@ def cerror(condition):
699714
We use the name "proceed" instead of Common Lisp's "continue", because in
700715
Python `continue` is a reserved word.
701716
717+
The optional `cause` argument is as in `unpythonic.misc.raisef`.
718+
In other words, if we pretend for a moment that `cerror` is a Python
719+
keyword, it essentially performs a `cerror ... from ...`. The default
720+
`cause=None` performs a plain `cerror ...`.
721+
702722
Example::
703723
704724
# If your condition needs data, it can be passed to __init__.
@@ -717,9 +737,9 @@ def __init__(self, value):
717737
718738
"""
719739
with restarts(proceed=(lambda: None)): # just for control, no return value
720-
error(condition)
740+
error(condition, cause=cause)
721741

722-
def warn(condition):
742+
def warn(condition, *, cause=None):
723743
"""Like `signal`, but emit a warning if the condition is not handled.
724744
725745
For emitting the warning, we use Python's standard `warnings.warn` mechanism.
@@ -732,6 +752,11 @@ def warn(condition):
732752
If not (i.e. some other subclass of `Exception` is being signaled), the
733753
generic category `Warning` is used, with the message set to `str(condition)`.
734754
755+
The optional `cause` argument is as in `unpythonic.misc.raisef`.
756+
In other words, if we pretend for a moment that `warn` is a Python
757+
keyword, it essentially performs a `warn ... from ...`. The default
758+
`cause=None` performs a plain `warn ...`.
759+
735760
Example::
736761
737762
class HelpMe(Warning):
@@ -762,7 +787,7 @@ def __init__(self, value):
762787
"""
763788
with restarts(muffle=(lambda: None)): # just for control, no return value
764789
with restarts(_proceed=(lambda: None)): # for internal use by unpythonic.test.fixtures
765-
signal(condition)
790+
signal(condition, cause=cause)
766791
if isinstance(condition, Warning):
767792
warnings.warn(condition, stacklevel=2) # 2 to ignore our lispy `warn` wrapper.
768793
else:

unpythonic/test/test_conditions.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,53 @@ def warn_protocol():
375375
test[unbox(result) == 42]
376376
warn_protocol()
377377

378+
# TODO: test the cause chain in the resulting ControlError from unhandled error(..., cause=...)
379+
with testset("signal with cause (signal-from)"):
380+
with handlers((JustTesting, lambda c: use_value(c))):
381+
with restarts(use_value=(lambda x: x)) as result:
382+
signal(JustTesting("Hullo")) # no cause
383+
test[unbox(result).__cause__ is None]
384+
385+
exc = JustTesting("Hello")
386+
with restarts(use_value=(lambda x: x)) as result:
387+
signal(JustTesting("Hullo"), cause=exc) # cause given, like "raise ... from ..."
388+
test[unbox(result).__cause__ is exc]
389+
390+
# The other protocols also support the cause parameter.
391+
with restarts(use_value=(lambda x: x)) as result:
392+
error(JustTesting("Hullo"), cause=exc)
393+
test[unbox(result).__cause__ is exc]
394+
395+
with restarts(use_value=(lambda x: x)) as result:
396+
cerror(JustTesting("Hullo"), cause=exc)
397+
test[unbox(result).__cause__ is exc]
398+
399+
with restarts(use_value=(lambda x: x)) as result:
400+
warn(JustTesting("Hullo"), cause=exc)
401+
test[unbox(result).__cause__ is exc]
402+
403+
# An unhandled `error` or `cerror`, when it **raises** `ControlError`,
404+
# sets the cause of that `ControlError` to the original unhandled signal.
405+
# In Python 3.7+, this will also produce nice stack traces.
406+
# In up to Python 3.6, it will at least show the chain of causes.
407+
with catch_signals(False):
408+
try:
409+
exc1 = JustTesting("Hullo")
410+
error(exc1)
411+
except ControlError as err:
412+
test[err.__cause__ is exc1]
413+
414+
# Causes can be chained, as with regular exceptions. Here's how
415+
# this interacts with an unhandled signal:
416+
try:
417+
exc1 = JustTesting("Hello")
418+
exc2 = JustTesting("Hullo")
419+
error(exc2, cause=exc1)
420+
except ControlError as err:
421+
test[err.__cause__ is exc2]
422+
test[err.__cause__.__cause__ is exc1]
423+
test[err.__cause__.__cause__.__cause__ is None]
424+
378425
# find_restart can be used to look for a restart before committing to
379426
# actually invoking it.
380427
with testset("find_restart"):

0 commit comments

Comments
 (0)