Repository: bluesky/bluesky
Pull request #1905 (2025-05-08 23:39:19)

Add `timeout` argument to `mv` and `mvr`
This change is intended a shorthand for the following operation (and for `rel_set` / `mvr`):

```python
yield from bps.abs_set(obj, X, group=Y)
yield from bps.wait(group=Y, timeout=Z)
```

, so we can instead simply write

```python
yield from bps.mv(obj, X, timeout=Z)
```

## Motivation and Context
I've come across many cases like this, where I'm moving regular signals (i.e not Positioners or anything of that sort), but the IOC or the hardware sometimes gets stuck, usually during some pre-scan configuration. In these cases, having a timeout is useful to give the user a meaningful indication of what has failed.





Patch:
diff --git a/src/bluesky/plan_stubs.py b/src/bluesky/plan_stubs.py
index ee9dcd55..d040e4a9 100644
--- a/src/bluesky/plan_stubs.py
+++ b/src/bluesky/plan_stubs.py
@@ -351,6 +351,7 @@ def rel_set(
 def mv(
     *args: Union[Movable, Any],
     group: Optional[Hashable] = None,
+    timeout: Optional[float] = None,
     **kwargs,
 ) -> MsgGenerator[tuple[Status, ...]]:
     """
@@ -364,6 +365,8 @@ def mv(
         device1, value1, device2, value2, ...
     group : string, optional
         Used to mark these as a unit to be waited on.
+    timeout : float, optional
+        Specify a maximum time that the move(s) can be waited for.
     kwargs :
         passed to obj.set()
 
@@ -389,7 +392,7 @@ def mv(
     for obj, val in step.items():
         ret = yield Msg("set", obj, val, group=group, **kwargs)
         status_objects.append(ret)
-    yield Msg("wait", None, group=group)
+    yield Msg("wait", None, group=group, timeout=timeout)
     return tuple(status_objects)
 
 
@@ -398,7 +401,7 @@ mov = mv  # synonym
 
 @plan
 def mvr(
-    *args: Union[Movable, Any], group: Optional[Hashable] = None, **kwargs
+    *args: Union[Movable, Any], group: Optional[Hashable] = None, timeout: Optional[float] = None, **kwargs
 ) -> MsgGenerator[tuple[Status, ...]]:
     """
     Move one or more devices to a relative setpoint. Wait for all to complete.
@@ -411,6 +414,8 @@ def mvr(
         device1, value1, device2, value2, ...
     group : string, optional
         Used to mark these as a unit to be waited on.
+    timeout : float, optional
+        Specify a maximum time that the move(s) can be waited for.
     kwargs :
         passed to obj.set()
 
@@ -436,7 +441,7 @@ def mvr(
 
     @relative_set_decorator(objs)
     def inner_mvr():
-        return (yield from mv(*args, group=group, **kwargs))
+        return (yield from mv(*args, group=group, timeout=timeout, **kwargs))
 
     return (yield from inner_mvr())
 


Test patch:
diff --git a/src/bluesky/tests/test_new_examples.py b/src/bluesky/tests/test_new_examples.py
index 7167ee75..4590480f 100644
--- a/src/bluesky/tests/test_new_examples.py
+++ b/src/bluesky/tests/test_new_examples.py
@@ -109,6 +109,26 @@ from bluesky.utils import IllegalMessageSequence, all_safe_rewind
             {"group": "A", "wait": True},
             [Msg("read", "det"), Msg("set", "det", 5, group="A"), Msg("wait", None, group="A")],
         ),
+        (
+            mv,
+            ("motor1", 1, "motor2", 2),
+            {"group": "A"},
+            [
+                Msg("set", "motor1", 1, group="A"),
+                Msg("set", "motor2", 2, group="A"),
+                Msg("wait", None, group="A", timeout=None),
+            ],
+        ),
+        (
+            mv,
+            ("motor1", 1, "motor2", 2),
+            {"group": "A", "timeout": 42},
+            [
+                Msg("set", "motor1", 1, group="A"),
+                Msg("set", "motor2", 2, group="A"),
+                Msg("wait", None, group="A", timeout=42),
+            ],
+        ),
         (trigger, ("det",), {}, [Msg("trigger", "det", group=None)]),
         (trigger, ("det",), {"group": "A"}, [Msg("trigger", "det", group="A")]),
         (sleep, (2,), {}, [Msg("sleep", None, 2)]),
@@ -147,28 +167,27 @@ def test_stub_plans(plan, plan_args, plan_kwargs, msgs, hw):
     assert list(plan(*plan_args, **plan_kwargs)) == msgs
 
 
-def test_mv(hw):
-    # special-case mv because the group is not configurable
-    # move motors first to ensure that movement is absolute, not relative
-    actual = list(mv(hw.motor1, 1, hw.motor2, 2))
-    strip_group(actual)
-    for msg in actual[:2]:
-        msg.command == "set"  # noqa: B015
-    assert set([msg.obj for msg in actual[:2]]) == set([hw.motor1, hw.motor2])  # noqa: C403, C405
-    assert actual[2] == Msg("wait", None)
+@pytest.mark.parametrize(("timeout", "should_fail"), [(0, True), (1, False), (None, False)])
+def test_mv_timeout(RE, hw, timeout, should_fail):
+    sig = hw.motor
+    sig.delay = 0.01
 
+    def tester(obj):
+        try:
+            yield from mv(obj, 1, timeout=timeout)
+        except TimeoutError:
+            assert should_fail
+        else:
+            assert not should_fail
 
-def test_mv_with_timeout(hw):
-    # special-case mv because the group is not configurable
-    # move motors first to ensure that movement is absolute, not relative
-    actual = list(mv(hw.motor1, 1, hw.motor2, 2, timeout=42))
-    for msg in actual[:2]:
-        msg.command == "set"  # noqa: B015
-        msg.kwargs["timeout"] == 42  # noqa: B015
+        # This needs to happen so all Futures can be cleared inside RE.
+        yield from sleep(obj.delay)
+
+    RE(tester(sig))
 
 
 def test_mvr(RE, hw):
-    # special-case mv because the group is not configurable
+    # special-case mvr because the value cannot be pre-defined in test_stub_plans
     # move motors first to ensure that movement is relative, not absolute
     hw.motor1.set(10)
     hw.motor2.set(10)
@@ -180,7 +199,7 @@ def test_mvr(RE, hw):
     for msg in actual[:2]:
         msg.command == "set"  # noqa: B015
     assert set([msg.obj for msg in actual[:2]]) == set([hw.motor1, hw.motor2])  # noqa: C403, C405
-    assert actual[2] == Msg("wait", None)
+    assert actual[2] == Msg("wait", None, timeout=None)
 
 
 def test_locatable_message_multiple_objects(RE, hw):
@@ -284,8 +303,9 @@ def test_mvr_with_timeout(hw):
     # move motors first to ensure that movement is absolute, not relative
     actual = list(mvr(hw.motor1, 1, hw.motor2, 2, timeout=42))
     for msg in actual[:2]:
-        msg.command == "set"  # noqa: B015
-        msg.kwargs["timeout"] == 42  # noqa: B015
+        assert msg.command == "set"  # noqa: B015
+
+    assert actual[2].kwargs["timeout"] == 42
 
 
 def strip_group(plan):
@@ -642,6 +662,7 @@ def test_configure_count_time(RE, hw):
 
     for msg in msgs:
         msg.kwargs.pop("group", None)
+        msg.kwargs.pop("timeout", None)
 
     assert msgs == expected