Skip to content

Commit 7219539

Browse files
authored
Merge pull request #197 from ISISComputingGroup/add_run_plan
Add run_plan
2 parents eb25f9f + 99b7fbc commit 7219539

File tree

4 files changed

+150
-7
lines changed

4 files changed

+150
-7
lines changed

doc/dev/troubleshooting.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,9 @@ This is not specific to bluesky - it's an IPython feature where `_` is bound to
185185
`__` is bound to the result of the second-to-last expression, and so on.
186186
```
187187

188-
[Do not try to "hide" the `RunEngine` in a script/function](https://blueskyproject.io/bluesky/main/tutorial.html#plans-in-series).
189-
The `RE(...)` call should always be typed by the user, at the terminal.
188+
[It is not advised to "hide" the `RunEngine` in a script/function](https://blueskyproject.io/bluesky/main/tutorial.html#plans-in-series).
189+
The `RE(...)` call should be typed by the user, at the terminal. If you *really* need to run a plan as part of a
190+
larger script, see {py:obj}`ibex_bluesky_core.run_engine.run_plan`.
190191

191192
### Connect a device
192193

doc/tutorial/overview.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ bluesky-native functionality - i.e. plans using `yield from`.
8181
8282
Carefully review [calling external code](../plan_stubs/external_code.md) if you do need to call
8383
external code in a plan.
84+
85+
If, on the other hand, you need to run a plan as part of a larger script, see
86+
{py:obj}`ibex_bluesky_core.run_engine.run_plan`.
8487
```
8588

8689
For more details about plan stubs (plan fragments like `mv` and `read`), see

src/ibex_bluesky_core/run_engine/__init__.py

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,19 @@
33
import asyncio
44
import functools
55
import logging
6+
from collections.abc import Generator
67
from functools import cache
7-
from threading import Event
8+
from threading import Event, Lock
9+
from typing import Any, cast
810

911
import bluesky.preprocessors as bpp
10-
from bluesky.run_engine import RunEngine
11-
from bluesky.utils import DuringTask
12+
from bluesky.run_engine import RunEngine, RunEngineResult
13+
from bluesky.utils import DuringTask, Msg, RunEngineControlException, RunEngineInterrupted
1214

1315
from ibex_bluesky_core.callbacks import DocLoggingCallback
1416
from ibex_bluesky_core.preprocessors import add_rb_number_processor
1517

16-
__all__ = ["get_run_engine"]
18+
__all__ = ["get_run_engine", "run_plan"]
1719

1820

1921
from ibex_bluesky_core.plan_stubs import CALL_QT_AWARE_MSG_KEY, CALL_SYNC_MSG_KEY
@@ -106,3 +108,81 @@ def get_run_engine() -> RunEngine:
106108
RE.preprocessors.append(functools.partial(bpp.plan_mutator, msg_proc=add_rb_number_processor))
107109

108110
return RE
111+
112+
113+
_RUN_PLAN_LOCK = Lock() # Explicitly *not* an RLock - RunEngine is not reentrant.
114+
115+
116+
def run_plan(
117+
plan: Generator[Msg, Any, Any],
118+
**metadata_kw: Any, # noqa ANN401 - this really does accept anything serializable
119+
) -> RunEngineResult:
120+
"""Run a plan.
121+
122+
.. Warning::
123+
124+
The usual way to run a plan in bluesky is by calling ``RE(plan(...))`` interactively.
125+
An ``RE`` object is already available in recent versions of the IBEX user interface,
126+
or can be acquired by calling ``get_run_engine()``.
127+
128+
Use of this function is **not recommended**, but it is nevertheless provided as an escape
129+
hatch for workflows which would otherwise be difficult to express or where parts of scanning
130+
scripts have not, or cannot, be migrated to bluesky.
131+
132+
Args:
133+
plan (positional-only): The plan to run. This is typically a generator instance.
134+
metadata_kw (optional, keyword-only): Keyword arguments (metadata) to pass to the bluesky
135+
run engine.
136+
137+
Returns:
138+
A ``RunEngineResult`` instance. The return value of the plan can then be accessed using
139+
the ``plan_result`` attribute.
140+
141+
Raises:
142+
RuntimeError: if the run engine was not idle at the start of this call.
143+
RuntimeError: if a reentrant call to the run engine is detected.
144+
:py:obj:`bluesky.utils.RunEngineInterrupted`: if the user, or the plan itself, explicitly
145+
requests an interruption.
146+
147+
Calling a plan using this function means that keyboard-interrupt handling will be
148+
degraded: all keyboard interrupts will now force an immediate abort of the plan, using
149+
``RE.abort()``, rather than giving the possibility of gracefully resuming. Cleanup handlers
150+
will execute during the ``RE.abort()``.
151+
152+
The bluesky run engine is not reentrant. It is a programming error to attempt to run a plan
153+
using this function, from within a plan. To call a sub plan from within an outer plan, use::
154+
155+
def outer_plan():
156+
...
157+
yield from subplan(...)
158+
159+
"""
160+
RE = get_run_engine()
161+
162+
if not _RUN_PLAN_LOCK.acquire(blocking=False):
163+
raise RuntimeError(
164+
"reentrant run_plan call attempted; this cannot be supported.\n"
165+
"It is a programming error to attempt to run a plan using run_plan "
166+
"from within a plan.\n"
167+
"To call a sub plan from within an outer plan, "
168+
"use 'yield from subplan(...)' instead.\n"
169+
)
170+
try:
171+
if RE.state != "idle":
172+
raise RuntimeError(
173+
"Cannot run plan; RunEngine is not idle at start of run_plan call. "
174+
"You may need to call RE.abort() to abort after a previous plan."
175+
)
176+
try:
177+
return cast(RunEngineResult, RE(plan, **metadata_kw))
178+
except (RunEngineInterrupted, RunEngineControlException):
179+
raise RunEngineInterrupted(
180+
"bluesky RunEngine interrupted; not resumable as running via run_plan"
181+
) from None
182+
finally:
183+
if RE.state != "idle":
184+
# Safest reasonable default? Don't want to halt()
185+
# as that wouldn't do cleanup e.g. dae settings
186+
RE.abort()
187+
finally:
188+
_RUN_PLAN_LOCK.release()

tests/test_run_engine.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
from unittest.mock import MagicMock
77

88
import bluesky.plan_stubs as bps
9+
import bluesky.preprocessors as bpp
910
import pytest
1011
from bluesky.run_engine import RunEngineResult
1112
from bluesky.utils import Msg, RequestAbort, RunEngineInterrupted
1213

13-
from ibex_bluesky_core.run_engine import _DuringTask, get_run_engine
14+
from ibex_bluesky_core.run_engine import _DuringTask, get_run_engine, run_plan
1415
from ibex_bluesky_core.version import version
1516

1617

@@ -87,3 +88,61 @@ def test_during_task_does_wait_with_small_timeout():
8788

8889
def test_runengine_has_version_number_as_metadata(RE):
8990
assert RE.md["versions"]["ibex_bluesky_core"] == version
91+
92+
93+
def test_run_plan_rejects_reentrant_call(RE):
94+
def _null():
95+
yield from bps.null()
96+
97+
def plan():
98+
yield from _null()
99+
run_plan(_null())
100+
101+
with pytest.raises(
102+
RuntimeError, match="reentrant run_plan call attempted; this cannot be supported"
103+
):
104+
run_plan(plan())
105+
106+
107+
def test_run_plan_rejects_call_if_re_already_busy(RE):
108+
def _null():
109+
yield from bps.null()
110+
111+
with pytest.raises(RunEngineInterrupted):
112+
RE(bps.pause())
113+
assert RE.state == "paused"
114+
with pytest.raises(RuntimeError):
115+
run_plan(_null())
116+
117+
RE.halt()
118+
119+
120+
def test_run_plan_runs_cleanup_on_interruption(RE):
121+
cleaned_up = False
122+
123+
def plan():
124+
def cleanup():
125+
yield from bps.null()
126+
nonlocal cleaned_up
127+
cleaned_up = True
128+
129+
yield from bpp.finalize_wrapper(bps.pause(), cleanup())
130+
131+
with pytest.raises(
132+
RunEngineInterrupted,
133+
match="bluesky RunEngine interrupted; not resumable as running via run_plan",
134+
):
135+
run_plan(plan())
136+
137+
assert RE.state == "idle"
138+
assert cleaned_up
139+
140+
141+
def test_run_plan_happy_path(RE):
142+
def plan():
143+
yield from bps.null()
144+
return "happy_path_result"
145+
146+
result = run_plan(plan())
147+
assert result.plan_result == "happy_path_result"
148+
assert result.exit_status == "success"

0 commit comments

Comments
 (0)