bec-ipython-client 3.142.0__tar.gz → 3.142.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/PKG-INFO +1 -1
  2. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/callbacks/device_progress.py +17 -26
  3. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/callbacks/ipython_live_updates.py +7 -7
  4. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/callbacks/move_device.py +15 -5
  5. bec_ipython_client-3.142.2/bec_ipython_client/callbacks/utils.py +273 -0
  6. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/pyproject.toml +3 -1
  7. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/tests/client_tests/test_device_progress.py +51 -0
  8. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/tests/client_tests/test_ipython_live_updates.py +87 -1
  9. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/tests/client_tests/test_move_callback.py +164 -0
  10. bec_ipython_client-3.142.0/bec_ipython_client/callbacks/utils.py +0 -165
  11. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/.gitignore +0 -0
  12. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/__init__.py +0 -0
  13. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/beamline_mixin.py +0 -0
  14. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/bec_magics.py +0 -0
  15. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/bec_startup.py +0 -0
  16. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/callbacks/__init__.py +0 -0
  17. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/callbacks/live_table.py +0 -0
  18. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/high_level_interfaces/__init__.py +0 -0
  19. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/high_level_interfaces/bec_hli.py +0 -0
  20. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/high_level_interfaces/spec_hli.py +0 -0
  21. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/main.py +0 -0
  22. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/plugins/SLS/__init__.py +0 -0
  23. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/plugins/SLS/sls_info.py +0 -0
  24. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/plugins/XTreme/__init__.py +0 -0
  25. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/plugins/XTreme/x-treme.py +0 -0
  26. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/plugins/__init__.py +0 -0
  27. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/plugins/flomni/flomni_config.yaml +0 -0
  28. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/prettytable.py +0 -0
  29. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/progressbar.py +0 -0
  30. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/bec_ipython_client/signals.py +0 -0
  31. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/demo.py +0 -0
  32. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/tests/client_tests/conftest.py +0 -0
  33. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/tests/client_tests/test_beamline_mixins.py +0 -0
  34. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/tests/client_tests/test_bec_client.py +0 -0
  35. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/tests/client_tests/test_live_table.py +0 -0
  36. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/tests/client_tests/test_pretty_table.py +0 -0
  37. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/tests/client_tests/test_signals.py +0 -0
  38. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/tests/conftest.py +0 -0
  39. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/tests/end-2-end/_ensure_requirements_container.py +0 -0
  40. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/tests/end-2-end/test_actors_e2e.py +0 -0
  41. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/tests/end-2-end/test_procedures_e2e.py +0 -0
  42. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/tests/end-2-end/test_scans_e2e.py +0 -0
  43. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/tests/end-2-end/test_scans_lib_e2e.py +0 -0
  44. {bec_ipython_client-3.142.0 → bec_ipython_client-3.142.2}/tests/end-2-end/test_scans_v4_lib_e2e.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bec_ipython_client
3
- Version: 3.142.0
3
+ Version: 3.142.2
4
4
  Summary: BEC IPython client
5
5
  Project-URL: Bug Tracker, https://github.com/bec-project/bec/issues
6
6
  Project-URL: Homepage, https://github.com/bec-project/bec
@@ -1,11 +1,11 @@
1
1
  import time
2
2
 
3
3
  from bec_ipython_client.progressbar import ScanProgressBar
4
- from bec_lib.bec_errors import ScanInterruption, ScanRestart
5
4
  from bec_lib.endpoints import MessageEndpoints
6
5
  from bec_lib.logger import bec_logger
7
6
 
8
7
  from .live_table import LiveUpdatesTable
8
+ from .utils import ScanState, evaluate_scan_state
9
9
 
10
10
  logger = bec_logger.logger
11
11
 
@@ -15,33 +15,14 @@ class LiveUpdatesDeviceProgress(LiveUpdatesTable):
15
15
 
16
16
  REPORT_TYPE = "device_progress"
17
17
 
18
- def _check_scan_state(self) -> bool:
18
+ def _check_scan_state(self) -> ScanState:
19
19
  """Check whether the scan has reached a terminal or exceptional state.
20
20
 
21
21
  Returns:
22
- bool: True if the scan should stop without error.
22
+ The current scan state outcome for the callback loop.
23
23
  """
24
- if not self.scan_item:
25
- return False
26
-
27
- restarted_msg = getattr(self.scan_item, "restarted_msg", None)
28
- if restarted_msg:
29
- raise ScanRestart(new_scan_msg=restarted_msg)
30
-
31
- if getattr(self.scan_item, "status", None) == "user_completed":
32
- print("Scan was set to 'completed' by user.")
33
- return True
34
-
35
- status_message = getattr(self.scan_item, "status_message", None)
36
- if status_message and getattr(status_message, "reason", None) == "user":
37
- scan_number = getattr(self.scan_item, "scan_number", None)
38
- if scan_number is None:
39
- msg = "Scan was aborted by user."
40
- else:
41
- msg = f"Scan {scan_number} was aborted by user."
42
- raise ScanInterruption(msg)
43
-
44
- return False
24
+ queue = self.scan_item.queue if self.scan_item else None
25
+ return evaluate_scan_state(scan_item=self.scan_item, queue=queue)
45
26
 
46
27
  def core(self):
47
28
  """core function to run the live updates for the table"""
@@ -73,8 +54,13 @@ class LiveUpdatesDeviceProgress(LiveUpdatesTable):
73
54
  bool: True if the scan is finished.
74
55
  """
75
56
  self.check_alarms()
76
- if self._check_scan_state():
57
+ scan_state = self._check_scan_state()
58
+ if scan_state == ScanState.DONE:
59
+ print("Scan was set to 'completed' by user.")
77
60
  return True
61
+ if scan_state == ScanState.WAIT:
62
+ time.sleep(0.05)
63
+ return False
78
64
  status = self.bec.connector.get(MessageEndpoints.device_progress(device_names[0]))
79
65
  if not status:
80
66
  logger.trace("waiting for new data point")
@@ -99,10 +85,15 @@ class LiveUpdatesDeviceProgress(LiveUpdatesTable):
99
85
  # process sync callbacks
100
86
  self.bec.callbacks.poll()
101
87
  self.scan_item.poll_callbacks()
102
- if self._check_scan_state():
88
+ scan_state = self._check_scan_state()
89
+ if scan_state == ScanState.DONE:
90
+ print("Scan was set to 'completed' by user.")
103
91
  return True
92
+ if scan_state == ScanState.WAIT:
93
+ return False
104
94
 
105
95
  done = status.content.get("done")
106
96
  if point_id == max_value or done:
107
97
  return True
98
+ time.sleep(0.05) # small sleep to avoid busy waiting
108
99
  return False
@@ -16,7 +16,7 @@ from bec_lib.request_context import ActiveRequestContext, active_request_context
16
16
 
17
17
  from .live_table import LiveUpdatesTable
18
18
  from .move_device import LiveUpdatesReadbackProgressbar
19
- from .utils import ScanRequestMixin, check_alarms
19
+ from .utils import ScanRequestMixin, ScanState, check_alarms, evaluate_scan_state
20
20
 
21
21
  if TYPE_CHECKING:
22
22
  from bec_lib import messages
@@ -202,7 +202,7 @@ class IPythonLiveUpdates:
202
202
  self._reset()
203
203
 
204
204
  except ScanRestart as scan_restart:
205
- self._stop_status_live()
205
+ self._reset()
206
206
  Console().print("[yellow]Scan restarted[/yellow]")
207
207
  request = scan_restart.new_scan_msg
208
208
  if request.allow_restart:
@@ -299,11 +299,11 @@ class IPythonLiveUpdates:
299
299
  check_alarms(self.client)
300
300
  if not queue.request_blocks or not queue.status:
301
301
  return False
302
- if queue.scans:
303
- if queue.scans[-1] is not None and queue.scans[-1].status == "user_completed":
304
- return True
305
- if queue.scans[-1] is None and queue.status in ["STOPPED", "CANCELLED"]:
306
- raise ScanInterruption("Scan was stopped by the user.")
302
+ queue_state = evaluate_scan_state(queue=queue)
303
+ if queue_state == ScanState.DONE:
304
+ return True
305
+ if queue_state == ScanState.WAIT:
306
+ return False
307
307
 
308
308
  if queue.queue_position is None:
309
309
  return False
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import threading
4
+ import time
4
5
  from collections.abc import Callable
5
6
  from typing import TYPE_CHECKING, cast
6
7
 
@@ -8,11 +9,10 @@ import numpy as np
8
9
 
9
10
  from bec_ipython_client.progressbar import DeviceProgressBar
10
11
  from bec_lib import messages
11
- from bec_lib.bec_errors import ScanInterruption
12
12
  from bec_lib.endpoints import MessageEndpoints
13
13
  from bec_lib.redis_connector import MessageObject
14
14
 
15
- from .utils import LiveUpdatesBase, check_alarms
15
+ from .utils import LiveUpdatesBase, ScanState, check_alarms, evaluate_scan_state
16
16
 
17
17
  if TYPE_CHECKING:
18
18
  from bec_lib.client import BECClient
@@ -200,10 +200,14 @@ class LiveUpdatesReadbackProgressbar(LiveUpdatesBase):
200
200
  with DeviceProgressBar(
201
201
  self.devices, start_values=start_values, target_values=target_values
202
202
  ) as progress:
203
- while not progress.finished or not data_source.done():
203
+ while True:
204
204
  check_alarms(self.bec)
205
- if self.scan_queue_request and self.scan_queue_request.queue.status == "STOPPED":
206
- raise ScanInterruption("Scan was stopped by the user.")
205
+ scan_state = self._check_scan_state()
206
+ if scan_state == ScanState.WAIT:
207
+ time.sleep(0.05)
208
+ continue
209
+ if progress.finished and data_source.done():
210
+ break
207
211
 
208
212
  values = data_source.get_device_values()
209
213
  progress.update(values=values)
@@ -221,3 +225,9 @@ class LiveUpdatesReadbackProgressbar(LiveUpdatesBase):
221
225
  def run(self):
222
226
  """run the progressbar."""
223
227
  self.core()
228
+
229
+ def _check_scan_state(self) -> ScanState:
230
+ """Check whether the tracked queue item has entered a terminal stop state."""
231
+ if self.scan_queue_request is None or self.scan_queue_request.queue is None:
232
+ return ScanState.CONTINUE
233
+ return evaluate_scan_state(queue=self.scan_queue_request.queue)
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ import time
5
+ import traceback
6
+ from collections.abc import Callable
7
+ from enum import Enum
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from bec_lib.bec_errors import ScanInterruption, ScanRestart
11
+ from bec_lib.logger import bec_logger
12
+ from bec_lib.request_items import RequestItem
13
+
14
+ if TYPE_CHECKING:
15
+ from bec_lib import messages
16
+ from bec_lib.client import BECClient
17
+ from bec_lib.queue_items import QueueItem
18
+ from bec_lib.scan_items import ScanItem
19
+
20
+ logger = bec_logger.logger
21
+
22
+
23
+ class ScanRequestError(Exception):
24
+ """Raised when the server rejects a scan request."""
25
+
26
+
27
+ class ScanState(Enum):
28
+ """Outcome of evaluating the current scan or queue state."""
29
+
30
+ CONTINUE = "continue"
31
+ DONE = "done"
32
+ WAIT = "wait"
33
+
34
+
35
+ def check_alarms(bec: BECClient) -> None:
36
+ """Raise any pending alarms for the active client."""
37
+ bec.alarm_handler.raise_alarms()
38
+
39
+
40
+ def evaluate_scan_state(
41
+ *, scan_item: ScanItem | None = None, queue: QueueItem | None = None
42
+ ) -> ScanState:
43
+ """Evaluate restart, completion, and interruption state for live callbacks.
44
+
45
+ Args:
46
+ scan_item: Scan-centric state, used by callbacks that poll a concrete scan item.
47
+ queue: Queue-centric state, used by callbacks that follow a queue entry directly.
48
+
49
+ Returns:
50
+ `ScanState.DONE` when the scan should stop cleanly.
51
+ `ScanState.WAIT` when the callback should keep polling, typically while a restart is in
52
+ progress and the replacement request has not arrived yet.
53
+ `ScanState.CONTINUE` when no terminal state has been reached.
54
+
55
+ Raises:
56
+ ScanRestart: When a restart message is already available.
57
+ ScanInterruption: When the scan ended in an interruption state.
58
+ """
59
+ current_scan = scan_item or _latest_scan(queue)
60
+ restarted_msg = _restart_signal(scan_item=scan_item, queue=queue)
61
+ if restarted_msg is not None:
62
+ raise ScanRestart(new_scan_msg=restarted_msg)
63
+
64
+ if getattr(current_scan, "status", None) == "user_completed":
65
+ return ScanState.DONE
66
+
67
+ if queue is not None:
68
+ queue_status = getattr(queue, "status", None)
69
+ if queue_status == "STOPPED" and getattr(queue, "reason", None) == "restart":
70
+ return ScanState.WAIT
71
+ if queue_status in ["STOPPED", "CANCELLED"]:
72
+ raise ScanInterruption(_interruption_message(queue_status, current_scan))
73
+
74
+ status_message = getattr(current_scan, "status_message", None)
75
+ if status_message and getattr(status_message, "reason", None) == "user":
76
+ raise ScanInterruption(_aborted_by_user_message(current_scan))
77
+
78
+ if getattr(current_scan, "status", None) in {"aborted", "halted"}:
79
+ raise ScanInterruption(_interrupted_message(current_scan))
80
+
81
+ return ScanState.CONTINUE
82
+
83
+
84
+ def _restart_signal(
85
+ *, scan_item: ScanItem | None = None, queue: QueueItem | None = None
86
+ ) -> messages.ScanQueueMessage | None:
87
+ """Return the restart message currently associated with the scan, if any."""
88
+ current_scan = scan_item or _latest_scan(queue)
89
+ restarted_msg = getattr(current_scan, "restarted_msg", None)
90
+ if restarted_msg is not None:
91
+ return restarted_msg
92
+ return None
93
+
94
+
95
+ def _latest_scan(queue: QueueItem | None) -> ScanItem | None:
96
+ """Return the latest scan item associated with a queue entry."""
97
+ if queue is None:
98
+ return None
99
+ scans = getattr(queue, "scans", None) or []
100
+ return scans[-1] if scans else None
101
+
102
+
103
+ def _scan_number(scan_item: ScanItem | None) -> int | None:
104
+ """Return the scan number when it is present and well-typed."""
105
+ scan_number = getattr(scan_item, "scan_number", None)
106
+ return scan_number if isinstance(scan_number, int) else None
107
+
108
+
109
+ def _aborted_by_user_message(scan_item: ScanItem | None) -> str:
110
+ """Build a user-facing message for a user-initiated abort."""
111
+ scan_number = _scan_number(scan_item)
112
+ if scan_number is None:
113
+ return "Scan was aborted by user."
114
+ return f"Scan {scan_number} was aborted by user."
115
+
116
+
117
+ def _interrupted_message(scan_item: ScanItem | None) -> str:
118
+ """Build a user-facing message for a non-user interruption."""
119
+ scan_number = _scan_number(scan_item)
120
+ if scan_number is None:
121
+ return "Scan was interrupted."
122
+ return f"Scan {scan_number} was interrupted."
123
+
124
+
125
+ def _interruption_message(queue_status: str, scan_item: ScanItem | None) -> str:
126
+ """Build a user-facing message for an interrupted queue entry."""
127
+ if queue_status == "CANCELLED":
128
+ return "Scan was cancelled."
129
+ status_message = getattr(scan_item, "status_message", None)
130
+ if status_message is not None and getattr(status_message, "reason", None) == "user":
131
+ return _aborted_by_user_message(scan_item)
132
+ return _interrupted_message(scan_item)
133
+
134
+
135
+ class LiveUpdatesBase(abc.ABC):
136
+ def __init__(
137
+ self,
138
+ bec: BECClient,
139
+ report_instruction: dict[str, Any] | None = None,
140
+ request: messages.ScanQueueMessage | None = None,
141
+ callbacks: list[Callable[..., Any]] | Callable[..., Any] | None = None,
142
+ ) -> None:
143
+ """Base class for live update callbacks.
144
+
145
+ Args:
146
+ bec: Active BEC client instance.
147
+ report_instruction: Callback-specific report instruction payload.
148
+ request: Scan queue request currently being processed.
149
+ callbacks: One or more user callbacks to invoke for emitted points.
150
+ """
151
+ self.bec = bec
152
+ self.request = request
153
+ self.RID = request.metadata["RID"]
154
+ self.scan_queue_request: RequestItem | None = None
155
+ self.report_instruction = report_instruction
156
+ if callbacks is None:
157
+ self.callbacks = []
158
+ self.callbacks = callbacks if isinstance(callbacks, list) else [callbacks]
159
+
160
+ def wait_for_request_acceptance(self):
161
+ scan_request = ScanRequestMixin(self.bec, self.RID)
162
+ scan_request.wait()
163
+ self.scan_queue_request = scan_request.scan_queue_request
164
+
165
+ @abc.abstractmethod
166
+ def run(self) -> None:
167
+ """Run the live update callback."""
168
+
169
+ def emit_point(self, data: dict[str, Any], metadata: dict[str, Any] | None = None) -> None:
170
+ """Emit a point update to all registered user callbacks."""
171
+ for cb in self.callbacks:
172
+ if not cb:
173
+ continue
174
+ try:
175
+ cb(data, metadata=metadata)
176
+ except Exception:
177
+ content = traceback.format_exc()
178
+ logger.warning(f"Failed to run callback function: {content}")
179
+
180
+ def _print_client_msgs_asap(self):
181
+ """Print queued client messages marked for immediate display."""
182
+ # pylint: disable=protected-access
183
+ if self.scan_queue_request is None:
184
+ return
185
+ queue = self.scan_queue_request.queue
186
+ if queue is None:
187
+ return
188
+ msgs = queue.get_client_messages(only_asap=True)
189
+ if not msgs:
190
+ return
191
+ if self.bec.live_updates_config.print_client_messages is False:
192
+ return
193
+ for msg in msgs:
194
+ print(queue.format_client_msg(msg))
195
+
196
+ def _print_client_msgs_all(self):
197
+ """Print a summary of all queued client messages."""
198
+ # pylint: disable=protected-access
199
+ if self.scan_queue_request is None:
200
+ return
201
+ queue = self.scan_queue_request.queue
202
+ if queue is None:
203
+ return
204
+ msgs = queue.get_client_messages()
205
+ if self.bec.live_updates_config.print_client_messages is False:
206
+ return
207
+ if not msgs:
208
+ return
209
+ print("------------------------")
210
+ print("Summary of client messages")
211
+ print("------------------------")
212
+ # pylint: disable=protected-access
213
+ for msg in msgs:
214
+ print(queue.format_client_msg(msg))
215
+ print("------------------------")
216
+
217
+
218
+ class ScanRequestMixin:
219
+ def __init__(self, bec: BECClient, RID: str) -> None:
220
+ """Mixin providing request-acceptance waiting helpers.
221
+
222
+ Args:
223
+ bec: Active BEC client instance.
224
+ RID: Request identifier to wait for.
225
+ """
226
+ self.bec = bec
227
+ self.request_storage = self.bec.queue.request_storage
228
+ self.RID = RID
229
+ self.scan_queue_request: RequestItem | None = None
230
+
231
+ def _wait_for_scan_request(self) -> RequestItem:
232
+ """Wait until the request item appears in request storage.
233
+
234
+ Returns:
235
+ The matching queue request item.
236
+ """
237
+ logger.trace("Waiting for request ID")
238
+ start = time.time()
239
+ while self.request_storage.find_request_by_ID(self.RID) is None:
240
+ time.sleep(0.1)
241
+ check_alarms(self.bec)
242
+ logger.trace(f"Waiting for request ID finished after {time.time()-start} s.")
243
+ return self.request_storage.find_request_by_ID(self.RID)
244
+
245
+ def _wait_for_scan_request_decision(self) -> None:
246
+ """Wait until the server has accepted or rejected the request."""
247
+ logger.trace("Waiting for decision")
248
+ start = time.time()
249
+ while self.scan_queue_request.decision_pending:
250
+ time.sleep(0.1)
251
+ check_alarms(self.bec)
252
+ logger.trace(f"Waiting for decision finished after {time.time()-start} s.")
253
+
254
+ def wait(self) -> None:
255
+ """Wait until the request is accepted and linked to a queue entry."""
256
+ self.scan_queue_request = self._wait_for_scan_request()
257
+
258
+ self._wait_for_scan_request_decision()
259
+ check_alarms(self.bec)
260
+
261
+ while self.scan_queue_request.accepted is None:
262
+ time.sleep(0.1)
263
+ check_alarms(self.bec)
264
+
265
+ if not self.scan_queue_request.accepted[0]:
266
+ raise ScanRequestError(
267
+ "Scan was rejected by the server:"
268
+ f" {self.scan_queue_request.response.content.get('message')}"
269
+ )
270
+
271
+ while self.scan_queue_request.queue is None:
272
+ time.sleep(0.1)
273
+ check_alarms(self.bec)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bec_ipython_client"
7
- version = "3.142.0"
7
+ version = "3.142.2"
8
8
  description = "BEC IPython client"
9
9
  requires-python = ">=3.11"
10
10
  classifiers = [
@@ -100,6 +100,8 @@ Homepage = "https://github.com/bec-project/bec"
100
100
 
101
101
 
102
102
 
103
+
104
+
103
105
 
104
106
 
105
107
 
@@ -3,6 +3,7 @@ from unittest import mock
3
3
  import pytest
4
4
 
5
5
  from bec_ipython_client.callbacks.device_progress import LiveUpdatesDeviceProgress
6
+ from bec_ipython_client.callbacks.utils import ScanState
6
7
  from bec_lib import messages
7
8
  from bec_lib.bec_errors import ScanInterruption, ScanRestart
8
9
 
@@ -92,6 +93,19 @@ def test_update_progressbar_raises_scan_restart_when_scan_restarted():
92
93
  mock_print.assert_not_called()
93
94
 
94
95
 
96
+ def test_check_scan_state_returns_wait_for_restart_reason():
97
+ bec = mock.MagicMock()
98
+ request = mock.MagicMock()
99
+ live_update = LiveUpdatesDeviceProgress(bec=bec, report_instruction={}, request=request)
100
+ live_update.scan_item = mock.MagicMock(
101
+ status="open",
102
+ restarted_msg=None,
103
+ queue=mock.MagicMock(status="STOPPED", reason="restart", scans=[mock.MagicMock()]),
104
+ )
105
+
106
+ assert live_update._check_scan_state() == ScanState.WAIT
107
+
108
+
95
109
  def test_update_progressbar_returns_true_when_scan_completed_by_user():
96
110
  bec = mock.MagicMock()
97
111
  request = mock.MagicMock()
@@ -123,3 +137,40 @@ def test_update_progressbar_raises_scan_interruption_when_aborted_by_user():
123
137
 
124
138
  with pytest.raises(ScanInterruption, match="Scan 5 was aborted by user."):
125
139
  live_update._update_progressbar(progressbar, ["async_dev1"])
140
+
141
+
142
+ def test_update_progressbar_waits_for_restart_message_before_finishing():
143
+ bec = mock.MagicMock()
144
+ request = mock.MagicMock()
145
+ live_update = LiveUpdatesDeviceProgress(bec=bec, report_instruction={}, request=request)
146
+ progressbar = mock.MagicMock()
147
+ restart_msg = messages.ScanQueueMessage(scan_type="grid_scan", parameter={"args": {}})
148
+ queue = mock.MagicMock(status="STOPPED", reason="restart")
149
+ scan_item = mock.MagicMock(
150
+ scan_id="scan_id",
151
+ scan_number=5,
152
+ restarted_msg=None,
153
+ status="open",
154
+ status_message=None,
155
+ queue=queue,
156
+ )
157
+ queue.scans = [scan_item]
158
+ live_update.scan_item = scan_item
159
+
160
+ bec.connector.get.return_value = messages.ProgressMessage(
161
+ value=10, max_value=10, done=True, metadata={"scan_id": "scan_id"}
162
+ )
163
+
164
+ with mock.patch("bec_ipython_client.callbacks.device_progress.time.sleep") as sleep:
165
+
166
+ def trigger_restart(*_args, **_kwargs):
167
+ scan_item.restarted_msg = restart_msg
168
+
169
+ sleep.side_effect = trigger_restart
170
+
171
+ first = live_update._update_progressbar(progressbar, ["async_dev1"])
172
+ assert first is False
173
+ with pytest.raises(ScanRestart) as exc_info:
174
+ live_update._update_progressbar(progressbar, ["async_dev1"])
175
+
176
+ assert exc_info.value.new_scan_msg == restart_msg
@@ -177,11 +177,97 @@ def test_live_updates_process_queue_cancelled_pending_request_raises_interruptio
177
177
  with (
178
178
  mock.patch.object(queue, "_update_with_buffer"),
179
179
  mock.patch("bec_lib.queue_items.QueueItem.queue_position", new_callable=mock.PropertyMock),
180
- pytest.raises(ScanInterruption, match="stopped by the user"),
180
+ pytest.raises(ScanInterruption, match="Scan was cancelled."),
181
181
  ):
182
182
  live_updates._process_queue(queue, request_msg, "something")
183
183
 
184
184
 
185
+ def test_live_updates_process_queue_stopped_started_request_raises_interruption(bec_client_mock):
186
+ client = bec_client_mock
187
+ live_updates = IPythonLiveUpdates(client)
188
+ request_msg = messages.ScanQueueMessage(
189
+ scan_type="grid_scan",
190
+ parameter={"args": {"samx": (-5, 5, 3)}, "kwargs": {}},
191
+ queue="primary",
192
+ metadata={"RID": "something"},
193
+ )
194
+ request_block = messages.RequestBlock(
195
+ msg=request_msg,
196
+ RID="something",
197
+ report_instructions=[],
198
+ readout_priority={"monitored": ["samx"]},
199
+ is_scan=True,
200
+ scan_number=1,
201
+ scan_id="scan_id",
202
+ )
203
+ queue = QueueItem(
204
+ scan_manager=client.queue,
205
+ queue_id="queue_id",
206
+ request_blocks=[request_block],
207
+ status="STOPPED",
208
+ active_request_block=None,
209
+ scan_id=["scan_id"],
210
+ )
211
+
212
+ with (
213
+ mock.patch.object(queue, "_update_with_buffer"),
214
+ mock.patch.object(
215
+ client.queue.scan_storage,
216
+ "find_scan_by_ID",
217
+ return_value=mock.MagicMock(
218
+ status="aborted",
219
+ scan_number=1,
220
+ restarted_msg=None,
221
+ status_message=mock.MagicMock(reason="user"),
222
+ ),
223
+ ),
224
+ mock.patch("bec_lib.queue_items.QueueItem.queue_position", new_callable=mock.PropertyMock),
225
+ pytest.raises(ScanInterruption, match="Scan 1 was aborted by user."),
226
+ ):
227
+ live_updates._process_queue(queue, request_msg, "something")
228
+
229
+
230
+ def test_live_updates_process_queue_stopped_restart_without_restart_message_waits_nonblocking(
231
+ bec_client_mock,
232
+ ):
233
+ client = bec_client_mock
234
+ live_updates = IPythonLiveUpdates(client)
235
+ request_msg = messages.ScanQueueMessage(
236
+ scan_type="grid_scan",
237
+ parameter={"args": {"samx": (-5, 5, 3)}, "kwargs": {}},
238
+ queue="primary",
239
+ metadata={"RID": "something"},
240
+ )
241
+ request_block = messages.RequestBlock(
242
+ msg=request_msg,
243
+ RID="something",
244
+ report_instructions=[],
245
+ readout_priority={"monitored": ["samx"]},
246
+ is_scan=True,
247
+ scan_number=1,
248
+ scan_id="scan_id",
249
+ )
250
+ queue = QueueItem(
251
+ scan_manager=client.queue,
252
+ queue_id="queue_id",
253
+ request_blocks=[request_block],
254
+ status="STOPPED",
255
+ active_request_block=None,
256
+ scan_id=["scan_id"],
257
+ reason="restart",
258
+ )
259
+
260
+ with (
261
+ mock.patch.object(queue, "_update_with_buffer"),
262
+ mock.patch.object(
263
+ client.queue.scan_storage,
264
+ "find_scan_by_ID",
265
+ return_value=mock.MagicMock(status="aborted", restarted_msg=None),
266
+ ),
267
+ ):
268
+ assert live_updates._process_queue(queue, request_msg, "something") is False
269
+
270
+
185
271
  def test_process_request_repeats_on_ScanRestart_error(
186
272
  ipython_live_updates_with_mocked_live, queue_elements
187
273
  ):
@@ -8,7 +8,9 @@ from bec_ipython_client.callbacks.move_device import (
8
8
  LiveUpdatesReadbackProgressbar,
9
9
  ReadbackDataHandler,
10
10
  )
11
+ from bec_ipython_client.callbacks.utils import ScanState
11
12
  from bec_lib import messages
13
+ from bec_lib.bec_errors import ScanInterruption, ScanRestart
12
14
  from bec_lib.endpoints import MessageEndpoints
13
15
 
14
16
 
@@ -103,6 +105,168 @@ def test_move_callback_with_report_instruction(bec_client_mock):
103
105
  ).run()
104
106
 
105
107
 
108
+ def test_move_callback_check_scan_state_raises_user_interruption(bec_client_mock):
109
+ request = messages.ScanQueueMessage(
110
+ scan_type="umv",
111
+ parameter={"args": {"samx": [10]}, "kwargs": {"relative": True}},
112
+ metadata={"RID": "something"},
113
+ )
114
+ live_update = LiveUpdatesReadbackProgressbar(bec=bec_client_mock, request=request)
115
+ live_update.scan_queue_request = mock.MagicMock(
116
+ queue=mock.MagicMock(
117
+ status="STOPPED",
118
+ scans=[
119
+ mock.MagicMock(
120
+ scan_number=5, restarted_msg=None, status_message=mock.MagicMock(reason="user")
121
+ )
122
+ ],
123
+ )
124
+ )
125
+
126
+ with pytest.raises(ScanInterruption, match="Scan 5 was aborted by user."):
127
+ live_update._check_scan_state()
128
+
129
+
130
+ def test_move_callback_check_scan_state_raises_scan_restart(bec_client_mock):
131
+ request = messages.ScanQueueMessage(
132
+ scan_type="umv",
133
+ parameter={"args": {"samx": [10]}, "kwargs": {"relative": True}},
134
+ metadata={"RID": "something"},
135
+ )
136
+ restart_msg = messages.ScanQueueMessage(
137
+ scan_type="umv",
138
+ parameter={"args": {"samx": [10]}, "kwargs": {"relative": True}},
139
+ metadata={"RID": "restart"},
140
+ )
141
+ live_update = LiveUpdatesReadbackProgressbar(bec=bec_client_mock, request=request)
142
+ live_update.scan_queue_request = mock.MagicMock(
143
+ queue=mock.MagicMock(
144
+ status="STOPPED",
145
+ scans=[
146
+ mock.MagicMock(
147
+ scan_number=5,
148
+ restarted_msg=restart_msg,
149
+ status_message=mock.MagicMock(reason="alarm"),
150
+ )
151
+ ],
152
+ )
153
+ )
154
+
155
+ with pytest.raises(ScanRestart) as exc_info:
156
+ live_update._check_scan_state()
157
+
158
+ assert exc_info.value.new_scan_msg == restart_msg
159
+
160
+
161
+ def test_move_callback_check_scan_state_raises_alarm_interruption(bec_client_mock):
162
+ request = messages.ScanQueueMessage(
163
+ scan_type="umv",
164
+ parameter={"args": {"samx": [10]}, "kwargs": {"relative": True}},
165
+ metadata={"RID": "something"},
166
+ allow_restart=False,
167
+ )
168
+ live_update = LiveUpdatesReadbackProgressbar(bec=bec_client_mock, request=request)
169
+ live_update.scan_queue_request = mock.MagicMock(
170
+ queue=mock.MagicMock(
171
+ status="STOPPED",
172
+ scans=[
173
+ mock.MagicMock(
174
+ scan_number=7, restarted_msg=None, status_message=mock.MagicMock(reason="alarm")
175
+ )
176
+ ],
177
+ )
178
+ )
179
+
180
+ with pytest.raises(ScanInterruption, match="Scan 7 was interrupted."):
181
+ live_update._check_scan_state()
182
+
183
+
184
+ def test_move_callback_check_scan_state_raises_cancelled_interruption(bec_client_mock):
185
+ request = messages.ScanQueueMessage(
186
+ scan_type="umv",
187
+ parameter={"args": {"samx": [10]}, "kwargs": {"relative": True}},
188
+ metadata={"RID": "something"},
189
+ )
190
+ live_update = LiveUpdatesReadbackProgressbar(bec=bec_client_mock, request=request)
191
+ live_update.scan_queue_request = mock.MagicMock(
192
+ queue=mock.MagicMock(status="CANCELLED", scans=[None])
193
+ )
194
+
195
+ with pytest.raises(ScanInterruption, match="Scan was cancelled."):
196
+ live_update._check_scan_state()
197
+
198
+
199
+ def test_move_callback_check_scan_state_restart_without_restart_message_is_nonblocking(
200
+ bec_client_mock,
201
+ ):
202
+ request = messages.ScanQueueMessage(
203
+ scan_type="umv",
204
+ parameter={"args": {"samx": [10]}, "kwargs": {"relative": True}},
205
+ metadata={"RID": "something"},
206
+ )
207
+ live_update = LiveUpdatesReadbackProgressbar(bec=bec_client_mock, request=request)
208
+ live_update.scan_queue_request = mock.MagicMock(
209
+ queue=mock.MagicMock(
210
+ status="STOPPED",
211
+ reason="restart",
212
+ scans=[
213
+ mock.MagicMock(restarted_msg=None, status_message=mock.MagicMock(reason="alarm"))
214
+ ],
215
+ )
216
+ )
217
+
218
+ assert live_update._check_scan_state() == ScanState.WAIT
219
+
220
+
221
+ def test_move_callback_run_waits_for_restart_message_before_exiting(bec_client_mock):
222
+ client = bec_client_mock
223
+ request = messages.ScanQueueMessage(
224
+ scan_type="umv",
225
+ parameter={"args": {"samx": [10]}, "kwargs": {"relative": True}},
226
+ metadata={"RID": "something"},
227
+ )
228
+ report_instruction = {
229
+ "readback": {"RID": "something", "devices": ["samx"], "start": [0], "end": [10]}
230
+ }
231
+ restart_msg = messages.ScanQueueMessage(
232
+ scan_type="umv",
233
+ parameter={"args": {"samx": [10]}, "kwargs": {"relative": True}},
234
+ metadata={"RID": "restart"},
235
+ )
236
+ live_update = LiveUpdatesReadbackProgressbar(
237
+ bec=client, report_instruction=report_instruction, request=request
238
+ )
239
+ queue = mock.MagicMock(
240
+ status="STOPPED",
241
+ reason="restart",
242
+ scans=[mock.MagicMock(restarted_msg=None, status_message=mock.MagicMock(reason="alarm"))],
243
+ )
244
+ live_update.scan_queue_request = mock.MagicMock(queue=queue)
245
+
246
+ with (
247
+ mock.patch("bec_ipython_client.callbacks.move_device.check_alarms"),
248
+ mock.patch.object(LiveUpdatesReadbackProgressbar, "wait_for_request_acceptance"),
249
+ mock.patch.object(LiveUpdatesReadbackProgressbar, "_print_client_msgs_asap"),
250
+ mock.patch.object(LiveUpdatesReadbackProgressbar, "_print_client_msgs_all"),
251
+ mock.patch.object(ReadbackDataHandler, "get_device_values", side_effect=[[0], [10]]),
252
+ mock.patch.object(
253
+ ReadbackDataHandler, "device_states", return_value={"samx": (True, True)}
254
+ ),
255
+ mock.patch.object(ReadbackDataHandler, "done", return_value=True),
256
+ mock.patch("bec_ipython_client.callbacks.move_device.time.sleep") as sleep,
257
+ ):
258
+
259
+ def trigger_restart(*_args, **_kwargs):
260
+ queue.scans[-1].restarted_msg = restart_msg
261
+
262
+ sleep.side_effect = trigger_restart
263
+
264
+ with pytest.raises(ScanRestart) as exc_info:
265
+ live_update.core()
266
+
267
+ assert exc_info.value.new_scan_msg == restart_msg
268
+
269
+
106
270
  def test_readback_data_handler(readback_data_handler):
107
271
  readback_data_handler.data = {
108
272
  "samx": messages.DeviceMessage(
@@ -1,165 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import abc
4
- import time
5
- import traceback
6
- from collections.abc import Callable
7
- from typing import TYPE_CHECKING
8
-
9
- from bec_lib.logger import bec_logger
10
- from bec_lib.request_items import RequestItem
11
-
12
- if TYPE_CHECKING:
13
- from bec_lib import messages
14
- from bec_lib.client import BECClient
15
-
16
- logger = bec_logger.logger
17
-
18
-
19
- class ScanRequestError(Exception):
20
- """Error raised when a scan request is rejected"""
21
-
22
-
23
- def check_alarms(bec):
24
- """check for alarms and raise them if needed"""
25
- bec.alarm_handler.raise_alarms()
26
-
27
-
28
- class LiveUpdatesBase(abc.ABC):
29
- def __init__(
30
- self,
31
- bec: BECClient,
32
- report_instruction: dict = None,
33
- request: messages.ScanQueueMessage = None,
34
- callbacks: list[Callable] = None,
35
- ) -> None:
36
- """Base class for live updates
37
-
38
- Args:
39
- bec (BECClient): BECClient instance
40
- report_instruction (dict, optional): report instruction. Defaults to None.
41
- request (messages.ScanQueueMessage, optional): scan queue request. Defaults to None.
42
- callbacks (list[Callable], optional): list of callback functions. Defaults to None.
43
- """
44
- self.bec = bec
45
- self.request = request
46
- self.RID = request.metadata["RID"]
47
- self.scan_queue_request: RequestItem | None = None
48
- self.report_instruction = report_instruction
49
- if callbacks is None:
50
- self.callbacks = []
51
- self.callbacks = callbacks if isinstance(callbacks, list) else [callbacks]
52
-
53
- def wait_for_request_acceptance(self):
54
- scan_request = ScanRequestMixin(self.bec, self.RID)
55
- scan_request.wait()
56
- self.scan_queue_request = scan_request.scan_queue_request
57
-
58
- @abc.abstractmethod
59
- def run(self):
60
- pass
61
-
62
- def emit_point(self, data: dict, metadata: dict = None):
63
- for cb in self.callbacks:
64
- if not cb:
65
- continue
66
- try:
67
- cb(data, metadata=metadata)
68
- except Exception:
69
- content = traceback.format_exc()
70
- logger.warning(f"Failed to run callback function: {content}")
71
-
72
- def _print_client_msgs_asap(self):
73
- """Print client messages flagged as show_asap"""
74
- # pylint: disable=protected-access
75
- if self.scan_queue_request is None:
76
- return
77
- queue = self.scan_queue_request.queue
78
- if queue is None:
79
- return
80
- msgs = queue.get_client_messages(only_asap=True)
81
- if not msgs:
82
- return
83
- if self.bec.live_updates_config.print_client_messages is False:
84
- return
85
- for msg in msgs:
86
- print(queue.format_client_msg(msg))
87
-
88
- def _print_client_msgs_all(self):
89
- """Print summary of client messages"""
90
- # pylint: disable=protected-access
91
- if self.scan_queue_request is None:
92
- return
93
- queue = self.scan_queue_request.queue
94
- if queue is None:
95
- return
96
- msgs = queue.get_client_messages()
97
- if self.bec.live_updates_config.print_client_messages is False:
98
- return
99
- if not msgs:
100
- return
101
- print("------------------------")
102
- print("Summary of client messages")
103
- print("------------------------")
104
- # pylint: disable=protected-access
105
- for msg in msgs:
106
- print(queue.format_client_msg(msg))
107
- print("------------------------")
108
-
109
-
110
- class ScanRequestMixin:
111
- def __init__(self, bec: BECClient, RID: str) -> None:
112
- """Mixin to handle scan request acceptance
113
-
114
- Args:
115
- bec (BECClient): BECClient instance
116
- RID (str): request ID
117
- """
118
- self.bec = bec
119
- self.request_storage = self.bec.queue.request_storage
120
- self.RID = RID
121
- self.scan_queue_request = None
122
-
123
- def _wait_for_scan_request(self) -> RequestItem:
124
- """wait for scan queuest
125
-
126
- Returns:
127
- RequestItem: scan queue request
128
- """
129
- logger.trace("Waiting for request ID")
130
- start = time.time()
131
- while self.request_storage.find_request_by_ID(self.RID) is None:
132
- time.sleep(0.1)
133
- check_alarms(self.bec)
134
- logger.trace(f"Waiting for request ID finished after {time.time()-start} s.")
135
- return self.request_storage.find_request_by_ID(self.RID)
136
-
137
- def _wait_for_scan_request_decision(self):
138
- """wait for a scan queuest decision"""
139
- logger.trace("Waiting for decision")
140
- start = time.time()
141
- while self.scan_queue_request.decision_pending:
142
- time.sleep(0.1)
143
- check_alarms(self.bec)
144
- logger.trace(f"Waiting for decision finished after {time.time()-start} s.")
145
-
146
- def wait(self):
147
- """wait for the request acceptance"""
148
- self.scan_queue_request = self._wait_for_scan_request()
149
-
150
- self._wait_for_scan_request_decision()
151
- check_alarms(self.bec)
152
-
153
- while self.scan_queue_request.accepted is None:
154
- time.sleep(0.1)
155
- check_alarms(self.bec)
156
-
157
- if not self.scan_queue_request.accepted[0]:
158
- raise ScanRequestError(
159
- "Scan was rejected by the server:"
160
- f" {self.scan_queue_request.response.content.get('message')}"
161
- )
162
-
163
- while self.scan_queue_request.queue is None:
164
- time.sleep(0.1)
165
- check_alarms(self.bec)