Repository: bluesky/bluesky
Pull request #1599 (2023-07-25 09:50:58)

ENH: Allow the contingency wrapper to eat exceptions
## Description
Add an option to contingency_wrapper to not automatically re-raise if the except plan returns a value rather than raising its own exception.

We have to do this with a flag like this for back-compatibility reasons

## Motivation and Context
We made a design mistake with contingency_wrapper because there is no way to actually suppress the exception. The reason we wrote this wrapper (and finalize wrapper) is to keep users from having to understand the extra set of constraints around the cancel and generator exit exceptions so this omission defeats the point.

## How Has This Been Tested?
Added unit tests that should cover all functionality. The first unit test added ensures backwards compatibility and also passes on `master`

## Note
Built on original PR https://github.com/bluesky/bluesky/pull/1526



Patch:
diff --git a/bluesky/preprocessors.py b/bluesky/preprocessors.py
index 207f89cc..50a6c272 100644
--- a/bluesky/preprocessors.py
+++ b/bluesky/preprocessors.py
@@ -535,7 +535,8 @@ def contingency_wrapper(plan, *,
                         except_plan=None,
                         else_plan=None,
                         final_plan=None,
-                        pause_for_debug=False):
+                        pause_for_debug=False,
+                        auto_raise=True):
     '''try...except...else...finally helper
 
     See :func:`finalize_wrapper` for a simplified but less powerful
@@ -561,6 +562,10 @@ def contingency_wrapper(plan, *,
     pause_for_debug : bool, optional
         If the plan should pause before running the clean final_plan in
         the case of an Exception.  This is intended as a debugging tool only.
+    auto_raise : bool, optional
+        If the exception should be always be re-raised, reagardless of what
+        except_plan does. Note this defaults to True for backwards compatibility,
+        which is not the usual behaviour of an except statement
 
     Yields
     ------
@@ -584,8 +589,13 @@ def contingency_wrapper(plan, *,
         if except_plan:
             # it might be better to throw this in, but this is simpler
             # to implement for now
-            yield from except_plan(e)
-        raise
+            ret = yield from except_plan(e)
+            if auto_raise:
+                raise
+            else:
+                return ret
+        else:
+            raise
     else:
         if else_plan:
             yield from else_plan()


Test patch:
diff --git a/bluesky/tests/test_preprocessors.py b/bluesky/tests/test_preprocessors.py
new file mode 100644
index 00000000..9b1279b5
--- /dev/null
+++ b/bluesky/tests/test_preprocessors.py
@@ -0,0 +1,79 @@
+import bluesky.plan_stubs as bps
+from bluesky.run_engine import RunEngine
+from bluesky.preprocessors import contingency_decorator
+import pytest
+from unittest.mock import MagicMock
+
+
+def test_given_a_plan_that_raises_contigency_will_call_except_plan_with_exception_and_run_engine_errors():
+    expected_exception = Exception()
+
+    def except_plan(exception: Exception):
+        assert exception == expected_exception
+        yield from bps.null()
+
+    # Mock so we can assert called
+    except_plan = MagicMock(side_effect=except_plan)
+
+    @contingency_decorator(except_plan=except_plan)
+    def raising_plan():
+        yield from bps.null()
+        raise expected_exception
+
+    RE = RunEngine()
+
+    with pytest.raises(Exception) as exception:
+        RE(raising_plan())
+        assert exception == expected_exception
+
+    except_plan.assert_called_once()
+
+
+def test_given_a_plan_that_raises_contigency_with_no_auto_raise_will_call_except_plan_and_RE_does_not_raise():
+    expected_exception = Exception()
+    expected_return_value = "test"
+
+    def except_plan(exception: Exception):
+        assert exception == expected_exception
+        yield from bps.null()
+        return expected_return_value
+
+    # Mock so we can assert called
+    except_plan = MagicMock(side_effect=except_plan)
+
+    @contingency_decorator(except_plan=except_plan, auto_raise=False)
+    def raising_plan():
+        yield from bps.null()
+        raise expected_exception
+
+    RE = RunEngine(call_returns_result=True)
+
+    returned_value = RE(raising_plan())
+
+    except_plan.assert_called_once()
+    assert returned_value.plan_result == expected_return_value
+
+
+def test_given_a_plan_that_raises_contigency_with_no_auto_raise_and_except_plan_that_reraises_run_engine_errors():
+    expected_exception = Exception()
+
+    def except_plan(exception: Exception):
+        assert exception == expected_exception
+        yield from bps.null()
+        raise exception
+
+    # Mock so we can assert called
+    except_plan = MagicMock(side_effect=except_plan)
+
+    @contingency_decorator(except_plan=except_plan, auto_raise=False)
+    def raising_plan():
+        yield from bps.null()
+        raise expected_exception
+
+    RE = RunEngine()
+
+    with pytest.raises(Exception) as exception:
+        RE(raising_plan())
+        assert exception == expected_exception
+
+    except_plan.assert_called_once()