bec-ipython-client 3.151.3__tar.gz → 3.153.0__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.151.3 → bec_ipython_client-3.153.0}/PKG-INFO +1 -1
  2. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/callbacks/ipython_live_updates.py +39 -34
  3. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/callbacks/live_table.py +6 -8
  4. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/callbacks/move_device.py +41 -1
  5. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/callbacks/utils.py +63 -0
  6. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/pyproject.toml +1 -1
  7. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/tests/client_tests/test_ipython_live_updates.py +169 -0
  8. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/tests/client_tests/test_live_table.py +33 -0
  9. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/tests/client_tests/test_move_callback.py +128 -1
  10. bec_ipython_client-3.153.0/tests/end-2-end/test_acl_e2e.py +140 -0
  11. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/tests/end-2-end/test_scans_e2e.py +41 -0
  12. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/.gitignore +0 -0
  13. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/__init__.py +0 -0
  14. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/beamline_mixin.py +0 -0
  15. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/bec_magics.py +0 -0
  16. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/bec_startup.py +0 -0
  17. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/callbacks/__init__.py +0 -0
  18. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/callbacks/device_progress.py +0 -0
  19. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/high_level_interfaces/__init__.py +0 -0
  20. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/high_level_interfaces/bec_hli.py +0 -0
  21. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/high_level_interfaces/spec_hli.py +0 -0
  22. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/main.py +0 -0
  23. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/plugins/SLS/__init__.py +0 -0
  24. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/plugins/SLS/sls_info.py +0 -0
  25. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/plugins/XTreme/__init__.py +0 -0
  26. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/plugins/XTreme/x-treme.py +0 -0
  27. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/plugins/__init__.py +0 -0
  28. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/plugins/flomni/flomni_config.yaml +0 -0
  29. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/prettytable.py +0 -0
  30. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/progressbar.py +0 -0
  31. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/bec_ipython_client/signals.py +0 -0
  32. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/demo.py +0 -0
  33. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/tests/client_tests/conftest.py +0 -0
  34. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/tests/client_tests/test_beamline_mixins.py +0 -0
  35. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/tests/client_tests/test_bec_client.py +0 -0
  36. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/tests/client_tests/test_device_progress.py +0 -0
  37. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/tests/client_tests/test_pretty_table.py +0 -0
  38. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/tests/client_tests/test_signals.py +0 -0
  39. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/tests/conftest.py +0 -0
  40. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/tests/end-2-end/_ensure_requirements_container.py +0 -0
  41. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/tests/end-2-end/test_actors_e2e.py +0 -0
  42. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/tests/end-2-end/test_procedures_e2e.py +0 -0
  43. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/tests/end-2-end/test_scans_lib_e2e.py +0 -0
  44. {bec_ipython_client-3.151.3 → bec_ipython_client-3.153.0}/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.151.3
3
+ Version: 3.153.0
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
@@ -16,7 +16,13 @@ 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, ScanState, check_alarms, evaluate_scan_state
19
+ from .utils import (
20
+ ScanRequestMixin,
21
+ ScanState,
22
+ check_alarms,
23
+ evaluate_scan_state,
24
+ pending_queue_message,
25
+ )
20
26
 
21
27
  if TYPE_CHECKING:
22
28
  from bec_lib import messages
@@ -308,7 +314,7 @@ class IPythonLiveUpdates:
308
314
  if queue.queue_position is None:
309
315
  return False
310
316
 
311
- if queue.status == "PENDING":
317
+ if self._is_pending_queue_state(queue):
312
318
  self._process_pending_queue_element(queue)
313
319
  return False
314
320
 
@@ -342,6 +348,28 @@ class IPythonLiveUpdates:
342
348
 
343
349
  return False
344
350
 
351
+ def _is_pending_queue_state(self, queue: QueueItem) -> bool:
352
+ """
353
+ Check if the queue is in a pending state, which can be due to either the queue
354
+ being locked or other scans being ahead in the queue.
355
+
356
+ Args:
357
+ queue(QueueItem): The queue item to check.
358
+
359
+ Returns:
360
+ bool: True if the queue is in a pending state, False otherwise.
361
+ """
362
+ if queue.status == "PENDING":
363
+ return True
364
+
365
+ active_request_block = queue.active_request_block
366
+ if active_request_block is not None and getattr(
367
+ active_request_block, "pending_device_locks", None
368
+ ):
369
+ return True
370
+
371
+ return False
372
+
345
373
  def _process_pending_queue_element(self, queue: QueueItem) -> None:
346
374
  """
347
375
  Process a pending queue element.
@@ -353,45 +381,22 @@ class IPythonLiveUpdates:
353
381
 
354
382
  if self.client.queue is None or self.client.queue.queue_storage.current_scan_queue is None:
355
383
  return
356
- target_queue = self.client.queue.get_default_scan_queue()
357
- target_queue_status = self.client.queue.queue_storage.current_scan_queue.get(target_queue)
358
- if target_queue_status is None:
359
- return
360
384
 
361
385
  queue_position = queue.queue_position
362
386
  if queue_position is None:
363
387
  return
364
388
 
365
- status = target_queue_status.status
366
- if status == "LOCKED" and any(queue.scan_ids):
367
- lock_info = [
368
- f"{lock.identifier}: {lock.reason}\n" for lock in target_queue_status.locks
369
- ]
370
- message = (
371
- f"Scan is waiting for the lock to be released. Active locks: \n{''.join(lock_info)}"
372
- )
373
- if self._status_live is None:
374
- self._status_live = Live(
375
- Panel(message, title="[yellow]Scan Status[/yellow]"), refresh_per_second=4
376
- )
377
- self._status_live.start()
378
- else:
379
- self._status_live.update(Panel(message, title="[yellow]Scan Status[/yellow]"))
389
+ message = pending_queue_message(bec=self.client, queue=queue, queue_position=queue_position)
390
+ if message is None:
380
391
  return
381
-
382
- if queue_position > 0:
383
- message = (
384
- f"Scan is enqueued and is waiting for execution. Current position in queue {target_queue}:"
385
- f" {queue_position + 1}. Queue status: {status}."
392
+ if self._status_live is None:
393
+ self._status_live = Live(
394
+ Panel(message, title="[yellow]Scan Status[/yellow]"), refresh_per_second=4
386
395
  )
387
- if self._status_live is None:
388
- self._status_live = Live(
389
- Panel(message, title="[yellow]Scan Status[/yellow]"), refresh_per_second=4
390
- )
391
- self._status_live.start()
392
- else:
393
- self._status_live.update(Panel(message, title="[yellow]Scan Status[/yellow]"))
394
- return
396
+ self._status_live.start()
397
+ else:
398
+ self._status_live.update(Panel(message, title="[yellow]Scan Status[/yellow]"))
399
+ return
395
400
 
396
401
  def _reset(self, forced=False):
397
402
  """Reset the active request and callback.
@@ -11,7 +11,7 @@ from bec_ipython_client.progressbar import ScanProgressBar
11
11
  from bec_lib.bec_errors import ScanInterruption, ScanRestart
12
12
  from bec_lib.logger import bec_logger
13
13
 
14
- from .utils import LiveUpdatesBase, check_alarms
14
+ from .utils import LiveUpdatesBase, check_alarms, pending_queue_message
15
15
 
16
16
  if TYPE_CHECKING:
17
17
  from bec_lib import messages
@@ -75,20 +75,18 @@ class LiveUpdatesTable(LiveUpdatesBase):
75
75
  while True:
76
76
  if not self.scan_item or not self.scan_item.queue:
77
77
  raise RuntimeError("No scan item or scan queue available.")
78
- queue_pos = self.scan_item.queue.queue_position
78
+ queue = self.scan_item.queue
79
+ queue_pos = queue.queue_position
79
80
  self.check_alarms()
80
81
  if self.scan_item.status == "closed":
81
82
  break
82
83
  if queue_pos is None:
83
84
  logger.trace(f"Could not find queue entry for scan_id {self.scan_item.scan_id}")
84
85
  continue
85
- if queue_pos == 0:
86
+ message = pending_queue_message(bec=self.bec, queue=queue, queue_position=queue_pos)
87
+ if message is None:
86
88
  break
87
- print(
88
- f"Scan is enqueued and is waiting for execution. Current position in queue: {queue_pos + 1}.",
89
- end="\r",
90
- flush=True,
91
- )
89
+ print(message, end="\r", flush=True)
92
90
  time.sleep(0.1)
93
91
  while not self.scan_item.scan_number:
94
92
  time.sleep(0.05)
@@ -12,7 +12,13 @@ from bec_lib import messages
12
12
  from bec_lib.endpoints import MessageEndpoints
13
13
  from bec_lib.redis_connector import MessageObject
14
14
 
15
- from .utils import LiveUpdatesBase, ScanState, check_alarms, evaluate_scan_state
15
+ from .utils import (
16
+ LiveUpdatesBase,
17
+ ScanState,
18
+ check_alarms,
19
+ evaluate_scan_state,
20
+ pending_queue_message,
21
+ )
16
22
 
17
23
  if TYPE_CHECKING:
18
24
  from bec_lib.client import BECClient
@@ -183,6 +189,7 @@ class LiveUpdatesReadbackProgressbar(LiveUpdatesBase):
183
189
  data_source = ReadbackDataHandler(self.bec.device_manager, self.devices, request_id)
184
190
  start_values = data_source.get_device_values()
185
191
  self.wait_for_request_acceptance()
192
+ self._wait_for_move_to_start(data_source)
186
193
 
187
194
  if self.report_instruction:
188
195
  target_values = self.report_instruction["readback"]["end"]
@@ -231,3 +238,36 @@ class LiveUpdatesReadbackProgressbar(LiveUpdatesBase):
231
238
  if self.scan_queue_request is None or self.scan_queue_request.queue is None:
232
239
  return ScanState.CONTINUE
233
240
  return evaluate_scan_state(queue=self.scan_queue_request.queue)
241
+
242
+ def _wait_for_move_to_start(self, data_source: ReadbackDataHandler) -> None:
243
+ """Wait until the move starts, mirroring the queue wait behavior of live table updates."""
244
+ if self.scan_queue_request is None or self.scan_queue_request.queue is None:
245
+ return
246
+
247
+ while True:
248
+ if self.scan_queue_request is None or self.scan_queue_request.queue is None:
249
+ return
250
+
251
+ queue = self.scan_queue_request.queue
252
+ check_alarms(self.bec)
253
+ scan_state = self._check_scan_state()
254
+ if scan_state == ScanState.WAIT:
255
+ time.sleep(0.05)
256
+ continue
257
+
258
+ queue_position = getattr(queue, "queue_position", None)
259
+
260
+ if queue_position is None:
261
+ if data_source.done():
262
+ return
263
+ time.sleep(0.05)
264
+ continue
265
+
266
+ message = pending_queue_message(
267
+ bec=self.bec, queue=queue, queue_position=queue_position
268
+ )
269
+ if message is None:
270
+ break
271
+
272
+ print(message, end="\r", flush=True)
273
+ time.sleep(0.1)
@@ -81,6 +81,69 @@ def evaluate_scan_state(
81
81
  return ScanState.CONTINUE
82
82
 
83
83
 
84
+ def pending_queue_message(
85
+ *, bec: BECClient, queue: QueueItem, queue_position: int | None = None
86
+ ) -> str | None:
87
+ """Return a user-facing message for queue states that are still waiting to start."""
88
+ if queue_position is None:
89
+ queue_position = getattr(queue, "queue_position", None)
90
+ if not isinstance(queue_position, int):
91
+ return None
92
+
93
+ target_queue = None
94
+ target_queue_status = None
95
+ if bec.queue is not None:
96
+ current_scan_queue = getattr(bec.queue.queue_storage, "current_scan_queue", None)
97
+ if current_scan_queue is not None:
98
+ target_queue = bec.queue.get_default_scan_queue()
99
+ target_queue_status = current_scan_queue.get(target_queue)
100
+
101
+ request_blocks = getattr(queue, "request_blocks", None) or []
102
+ has_scan_request = any(
103
+ getattr(request_block, "is_scan", False) for request_block in request_blocks
104
+ )
105
+ if (
106
+ target_queue_status is not None
107
+ and getattr(target_queue_status, "status", None) == "LOCKED"
108
+ and has_scan_request
109
+ ):
110
+ lock_info = [
111
+ f"{lock.identifier}: {lock.reason}\n"
112
+ for lock in getattr(target_queue_status, "locks", None) or []
113
+ ]
114
+ return f"Scan is waiting for the lock to be released. Active locks: \n{''.join(lock_info)}"
115
+
116
+ owned_device_locks: list[str] = []
117
+ pending_device_locks: list[str] = []
118
+ get_device_lock_state = getattr(queue, "get_device_lock_state", None)
119
+ if callable(get_device_lock_state):
120
+ try:
121
+ owned_device_locks, pending_device_locks = get_device_lock_state()
122
+ except Exception:
123
+ owned_device_locks, pending_device_locks = [], []
124
+ if pending_device_locks:
125
+ pending_locks = ", ".join(pending_device_locks)
126
+ message = f"Scan is waiting for device locks to be released. Pending device locks: {pending_locks}."
127
+ if owned_device_locks:
128
+ owned_locks = ", ".join(owned_device_locks)
129
+ message += f" Currently owned device locks: {owned_locks}."
130
+ return message
131
+
132
+ if getattr(queue, "status", None) == "PENDING" and queue_position > 0:
133
+ if target_queue is not None:
134
+ status = getattr(target_queue_status, "status", getattr(queue, "status", None))
135
+ return (
136
+ "Scan is enqueued and is waiting for execution. "
137
+ f"Current position in queue {target_queue}: {queue_position + 1}. Queue status: {status}."
138
+ )
139
+ return (
140
+ "Scan is enqueued and is waiting for execution. "
141
+ f"Current position in queue: {queue_position + 1}."
142
+ )
143
+
144
+ return None
145
+
146
+
84
147
  def _restart_signal(
85
148
  *, scan_item: ScanItem | None = None, queue: QueueItem | None = None
86
149
  ) -> messages.ScanQueueMessage | None:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bec_ipython_client"
7
- version = "3.151.3"
7
+ version = "3.153.0"
8
8
  description = "BEC IPython client"
9
9
  requires-python = ">=3.11"
10
10
  classifiers = [
@@ -147,6 +147,41 @@ def test_live_updates_process_queue_running(ipython_live_updates_with_mocked_liv
147
147
  assert res is True
148
148
 
149
149
 
150
+ @pytest.mark.timeout(20)
151
+ def test_live_updates_process_queue_waiting_for_device_locks(
152
+ ipython_live_updates_with_mocked_live, queue_elements
153
+ ):
154
+ live_updates, _ = ipython_live_updates_with_mocked_live
155
+ client = live_updates.client
156
+ queue, request_block, request_msg = queue_elements
157
+
158
+ request_block.pending_device_locks = ["samx"]
159
+ queue = QueueItem(
160
+ scan_manager=client.queue,
161
+ queue_id="queue_id",
162
+ request_blocks=[request_block],
163
+ status="RUNNING",
164
+ active_request_block=request_block,
165
+ scan_id=["scan_id"],
166
+ )
167
+ client.queue.queue_storage.current_scan_queue = {
168
+ "primary": messages.ScanQueueStatus(info=[], status="RUNNING")
169
+ }
170
+
171
+ with (
172
+ mock.patch.object(queue, "_update_with_buffer"),
173
+ mock.patch(
174
+ "bec_lib.queue_items.QueueItem.queue_position", new_callable=mock.PropertyMock
175
+ ) as queue_pos,
176
+ mock.patch.object(live_updates, "_process_pending_queue_element") as process_pending,
177
+ ):
178
+ queue_pos.return_value = 0
179
+
180
+ assert live_updates._process_queue(queue, request_msg, "req_id") is False
181
+
182
+ process_pending.assert_called_once_with(queue)
183
+
184
+
150
185
  def test_live_updates_process_queue_cancelled_pending_request_raises_interruption(bec_client_mock):
151
186
  client = bec_client_mock
152
187
  live_updates = IPythonLiveUpdates(client)
@@ -1016,3 +1051,137 @@ def test_process_pending_queue_element_locked_then_position(
1016
1051
  assert "user123" in panel_renderable
1017
1052
  # Should not show position info
1018
1053
  assert "position in queue" not in panel_renderable
1054
+
1055
+
1056
+ @pytest.mark.timeout(20)
1057
+ def test_process_pending_queue_element_waiting_for_device_locks(
1058
+ ipython_live_updates_with_mocked_live, queue_elements
1059
+ ):
1060
+ live_updates, mock_live = ipython_live_updates_with_mocked_live
1061
+ client = live_updates.client
1062
+ queue, request_block, _ = queue_elements
1063
+
1064
+ request_block.pending_device_locks = ["bpm4i", "samz"]
1065
+ request_block.owned_device_locks = ["samx"]
1066
+ queue.active_request_block = request_block
1067
+
1068
+ running_scan_queue_status = messages.ScanQueueStatus(info=[], status="RUNNING")
1069
+ client.queue.queue_storage.current_scan_queue = {"primary": running_scan_queue_status}
1070
+
1071
+ with mock.patch(
1072
+ "bec_lib.queue_items.QueueItem.queue_position", new_callable=mock.PropertyMock
1073
+ ) as queue_pos:
1074
+ queue_pos.return_value = 0
1075
+
1076
+ live_updates._process_pending_queue_element(queue)
1077
+
1078
+ mock_live.assert_called_once()
1079
+ call_args = mock_live.call_args[0][0]
1080
+ panel_renderable = call_args.renderable
1081
+ assert "waiting for device locks" in panel_renderable
1082
+ assert "bpm4i, samz" in panel_renderable
1083
+ assert "Currently owned device locks: samx" in panel_renderable
1084
+
1085
+
1086
+ @pytest.mark.timeout(20)
1087
+ def test_process_pending_queue_element_waiting_for_device_locks_without_scan_id(
1088
+ ipython_live_updates_with_mocked_live, queue_elements
1089
+ ):
1090
+ live_updates, mock_live = ipython_live_updates_with_mocked_live
1091
+ client = live_updates.client
1092
+ queue, request_block, _ = queue_elements
1093
+
1094
+ request_block.pending_device_locks = ["bpm4i", "samz"]
1095
+ request_block.owned_device_locks = ["samx"]
1096
+ queue.active_request_block = request_block
1097
+ queue.scan_ids = [None]
1098
+
1099
+ running_scan_queue_status = messages.ScanQueueStatus(info=[], status="RUNNING")
1100
+ client.queue.queue_storage.current_scan_queue = {"primary": running_scan_queue_status}
1101
+
1102
+ with mock.patch(
1103
+ "bec_lib.queue_items.QueueItem.queue_position", new_callable=mock.PropertyMock
1104
+ ) as queue_pos:
1105
+ queue_pos.return_value = 0
1106
+
1107
+ live_updates._process_pending_queue_element(queue)
1108
+
1109
+ mock_live.assert_called_once()
1110
+ call_args = mock_live.call_args[0][0]
1111
+ panel_renderable = call_args.renderable
1112
+ assert "waiting for device locks" in panel_renderable
1113
+ assert "bpm4i, samz" in panel_renderable
1114
+ assert "Currently owned device locks: samx" in panel_renderable
1115
+
1116
+
1117
+ @pytest.mark.timeout(20)
1118
+ def test_process_pending_queue_element_waiting_for_device_locks_beats_queue_position(
1119
+ ipython_live_updates_with_mocked_live, queue_elements
1120
+ ):
1121
+ live_updates, mock_live = ipython_live_updates_with_mocked_live
1122
+ client = live_updates.client
1123
+ queue, request_block, _ = queue_elements
1124
+
1125
+ request_block.pending_device_locks = ["bpm4i"]
1126
+ queue.active_request_block = request_block
1127
+
1128
+ running_scan_queue_status = messages.ScanQueueStatus(info=[], status="RUNNING")
1129
+ client.queue.queue_storage.current_scan_queue = {"primary": running_scan_queue_status}
1130
+
1131
+ with mock.patch(
1132
+ "bec_lib.queue_items.QueueItem.queue_position", new_callable=mock.PropertyMock
1133
+ ) as queue_pos:
1134
+ queue_pos.return_value = 2
1135
+
1136
+ live_updates._process_pending_queue_element(queue)
1137
+
1138
+ call_args = mock_live.call_args[0][0]
1139
+ panel_renderable = call_args.renderable
1140
+ assert "waiting for device locks" in panel_renderable
1141
+ assert "position in queue" not in panel_renderable
1142
+
1143
+
1144
+ @pytest.mark.timeout(20)
1145
+ def test_process_pending_queue_element_waiting_for_device_locks_on_secondary_queue(
1146
+ ipython_live_updates_with_mocked_live, queue_elements
1147
+ ):
1148
+ live_updates, mock_live = ipython_live_updates_with_mocked_live
1149
+ client = live_updates.client
1150
+ queue, request_block, _ = queue_elements
1151
+
1152
+ queue.queue_id = "secondary_queue_id"
1153
+ request_block.pending_device_locks = ["bpm4i"]
1154
+ request_block.owned_device_locks = ["samx"]
1155
+ queue.active_request_block = request_block
1156
+
1157
+ client.queue.queue_storage.current_scan_queue = {
1158
+ "primary": messages.ScanQueueStatus(info=[], status="RUNNING"),
1159
+ "secondary": messages.ScanQueueStatus(
1160
+ info=[
1161
+ messages.QueueInfoEntry(
1162
+ queue_id="secondary_queue_id",
1163
+ scan_id=["scan_id"],
1164
+ is_scan=[True],
1165
+ request_blocks=[request_block],
1166
+ scan_number=[1],
1167
+ status="PENDING",
1168
+ active_request_block=request_block,
1169
+ )
1170
+ ],
1171
+ status="RUNNING",
1172
+ ),
1173
+ }
1174
+
1175
+ with mock.patch(
1176
+ "bec_lib.queue_items.QueueItem.queue_position", new_callable=mock.PropertyMock
1177
+ ) as queue_pos:
1178
+ queue_pos.return_value = 0
1179
+
1180
+ live_updates._process_pending_queue_element(queue)
1181
+
1182
+ mock_live.assert_called_once()
1183
+ call_args = mock_live.call_args[0][0]
1184
+ panel_renderable = call_args.renderable
1185
+ assert "waiting for device locks" in panel_renderable
1186
+ assert "bpm4i" in panel_renderable
1187
+ assert "Currently owned device locks: samx" in panel_renderable
@@ -130,6 +130,39 @@ class TestLiveTable:
130
130
  with mock.patch.object(client.queue.queue_storage, "find_queue_item_by_requestID"):
131
131
  live_update.wait_for_request_acceptance()
132
132
 
133
+ def test_wait_for_scan_to_start_prints_pending_queue_message(self, client_with_grid_scan):
134
+ client, request_msg = client_with_grid_scan
135
+ live_update = LiveUpdatesTable(
136
+ client, {"scan_progress": {"points": 10, "show_table": True}}, request_msg
137
+ )
138
+ client.queue.queue_storage.current_scan_queue = None
139
+
140
+ class QueueStub:
141
+ def __init__(self):
142
+ self.status = "PENDING"
143
+ self.request_blocks = []
144
+ self._positions = iter([2, 0])
145
+
146
+ @property
147
+ def queue_position(self):
148
+ return next(self._positions)
149
+
150
+ def get_device_lock_state(self):
151
+ return ([], [])
152
+
153
+ live_update.scan_item = mock.MagicMock(
154
+ queue=QueueStub(), status="open", scan_id="scan_id", scan_number=1
155
+ )
156
+
157
+ with mock.patch("bec_ipython_client.callbacks.live_table.print") as mock_print:
158
+ live_update.wait_for_scan_to_start()
159
+
160
+ mock_print.assert_called_once_with(
161
+ "Scan is enqueued and is waiting for execution. Current position in queue: 3.",
162
+ end="\r",
163
+ flush=True,
164
+ )
165
+
133
166
  @pytest.mark.timeout(20)
134
167
  def test_run_update(self, bec_client_mock, scan_item):
135
168
  request_msg = messages.ScanQueueMessage(
@@ -8,7 +8,7 @@ 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
+ from bec_ipython_client.callbacks.utils import ScanState, pending_queue_message
12
12
  from bec_lib import messages
13
13
  from bec_lib.bec_errors import ScanInterruption, ScanRestart
14
14
  from bec_lib.endpoints import MessageEndpoints
@@ -218,6 +218,133 @@ def test_move_callback_check_scan_state_restart_without_restart_message_is_nonbl
218
218
  assert live_update._check_scan_state() == ScanState.WAIT
219
219
 
220
220
 
221
+ def test_move_callback_pending_queue_message_for_device_locks(bec_client_mock):
222
+ bec_client_mock.queue.queue_storage.current_scan_queue = None
223
+ request_block = mock.MagicMock(is_scan=True)
224
+ queue = mock.MagicMock(status="RUNNING", request_blocks=[request_block])
225
+ queue.get_device_lock_state.return_value = (["samz"], ["samx", "bpm4i"])
226
+
227
+ message = pending_queue_message(bec=bec_client_mock, queue=queue, queue_position=0)
228
+
229
+ assert (
230
+ message
231
+ == "Scan is waiting for device locks to be released. Pending device locks: samx, bpm4i. "
232
+ "Currently owned device locks: samz."
233
+ )
234
+
235
+
236
+ def test_move_callback_pending_queue_message_for_queue_position(bec_client_mock):
237
+ bec_client_mock.queue.queue_storage.current_scan_queue = None
238
+ queue = mock.MagicMock(status="PENDING", request_blocks=[])
239
+ queue.get_device_lock_state.return_value = ([], [])
240
+
241
+ message = pending_queue_message(bec=bec_client_mock, queue=queue, queue_position=2)
242
+
243
+ assert message == "Scan is enqueued and is waiting for execution. Current position in queue: 3."
244
+
245
+
246
+ def test_move_callback_wait_for_move_to_start_prints_pending_message(bec_client_mock):
247
+ request = messages.ScanQueueMessage(
248
+ scan_type="umv",
249
+ parameter={"args": {"samx": [10]}, "kwargs": {"relative": True}},
250
+ metadata={"RID": "something"},
251
+ )
252
+ live_update = LiveUpdatesReadbackProgressbar(bec=bec_client_mock, request=request)
253
+ bec_client_mock.queue.queue_storage.current_scan_queue = None
254
+
255
+ class QueueStub:
256
+ def __init__(self):
257
+ self.status = "PENDING"
258
+ self.request_blocks = []
259
+ self._positions = iter([2, 0])
260
+
261
+ @property
262
+ def queue_position(self):
263
+ return next(self._positions)
264
+
265
+ def get_device_lock_state(self):
266
+ return ([], [])
267
+
268
+ queue = QueueStub()
269
+ live_update.scan_queue_request = mock.MagicMock(queue=queue)
270
+ data_source = mock.MagicMock()
271
+ data_source.done.return_value = False
272
+
273
+ with (
274
+ mock.patch("bec_ipython_client.callbacks.move_device.print") as mock_print,
275
+ mock.patch("bec_ipython_client.callbacks.move_device.time.sleep") as sleep,
276
+ mock.patch.object(live_update, "_check_scan_state", return_value=ScanState.CONTINUE),
277
+ ):
278
+ live_update._wait_for_move_to_start(data_source)
279
+
280
+ mock_print.assert_called_once_with(
281
+ "Scan is enqueued and is waiting for execution. Current position in queue: 3.",
282
+ end="\r",
283
+ flush=True,
284
+ )
285
+ sleep.assert_called()
286
+
287
+
288
+ def test_move_callback_wait_for_move_to_start_returns_when_queue_entry_is_gone(bec_client_mock):
289
+ request = messages.ScanQueueMessage(
290
+ scan_type="umv",
291
+ parameter={"args": {"samx": [10]}, "kwargs": {"relative": True}},
292
+ metadata={"RID": "something"},
293
+ )
294
+ live_update = LiveUpdatesReadbackProgressbar(bec=bec_client_mock, request=request)
295
+ queue = mock.MagicMock()
296
+ queue.queue_position = None
297
+ live_update.scan_queue_request = mock.MagicMock(queue=queue)
298
+ data_source = mock.MagicMock()
299
+ data_source.done.return_value = True
300
+
301
+ with (
302
+ mock.patch("bec_ipython_client.callbacks.move_device.print") as mock_print,
303
+ mock.patch("bec_ipython_client.callbacks.move_device.time.sleep") as sleep,
304
+ mock.patch.object(live_update, "_check_scan_state", return_value=ScanState.CONTINUE),
305
+ ):
306
+ live_update._wait_for_move_to_start(data_source)
307
+
308
+ mock_print.assert_not_called()
309
+ sleep.assert_not_called()
310
+
311
+
312
+ def test_move_callback_wait_for_move_to_start_keeps_waiting_when_not_yet_registered(
313
+ bec_client_mock,
314
+ ):
315
+ request = messages.ScanQueueMessage(
316
+ scan_type="umv",
317
+ parameter={"args": {"samx": [10]}, "kwargs": {"relative": True}},
318
+ metadata={"RID": "something"},
319
+ )
320
+ live_update = LiveUpdatesReadbackProgressbar(bec=bec_client_mock, request=request)
321
+
322
+ class QueueStub:
323
+ def __init__(self):
324
+ self._positions = iter([None, None, 0])
325
+
326
+ @property
327
+ def queue_position(self):
328
+ return next(self._positions)
329
+
330
+ live_update.scan_queue_request = mock.MagicMock(queue=QueueStub())
331
+ data_source = mock.MagicMock()
332
+ data_source.done.return_value = False
333
+
334
+ with (
335
+ mock.patch("bec_ipython_client.callbacks.move_device.print") as mock_print,
336
+ mock.patch("bec_ipython_client.callbacks.move_device.time.sleep") as sleep,
337
+ mock.patch.object(live_update, "_check_scan_state", return_value=ScanState.CONTINUE),
338
+ mock.patch(
339
+ "bec_ipython_client.callbacks.move_device.pending_queue_message", return_value=None
340
+ ),
341
+ ):
342
+ live_update._wait_for_move_to_start(data_source)
343
+
344
+ mock_print.assert_not_called()
345
+ assert sleep.call_count == 2
346
+
347
+
221
348
  def test_move_callback_run_waits_for_restart_message_before_exiting(bec_client_mock):
222
349
  client = bec_client_mock
223
350
  request = messages.ScanQueueMessage(
@@ -0,0 +1,140 @@
1
+ import shutil
2
+
3
+ import pytest
4
+ import redis.exceptions
5
+
6
+ from bec_lib import messages
7
+ from bec_lib.client import BECClient
8
+ from bec_lib.endpoints import MessageEndpoints
9
+ from bec_lib.redis_connector import RedisConnector
10
+ from bec_lib.service_config import ServiceConfig, ServiceConfigModel
11
+ from bec_lib.tests.utils import wait_for_empty_queue
12
+ from bec_lib.utils.user_acls_test import BECAccessDemo
13
+ from bec_server.bec_server_utils.service_handler import ServiceHandler
14
+
15
+
16
+ @pytest.fixture
17
+ def acl_enabled_bec_services(
18
+ request,
19
+ bec_files_path,
20
+ bec_services_config_file_path,
21
+ bec_test_config_file_path,
22
+ bec_redis_host_port,
23
+ test_config_yaml_file_path,
24
+ ):
25
+ if not request.config.getoption("--start-servers") or not request.config.getoption(
26
+ "--flush-redis"
27
+ ):
28
+ pytest.skip("ACL e2e test mutates Redis ACLs and requires isolated pytest BEC services.")
29
+
30
+ redis_host, redis_port = bec_redis_host_port
31
+ redis_url = f"{redis_host}:{redis_port}"
32
+ acl_env_file = bec_services_config_file_path.parent / ".bec_acl.env"
33
+ acl_env_file.write_text("REDIS_USER=admin\nREDIS_PASSWORD=admin\n", encoding="utf-8")
34
+ shutil.copyfile(test_config_yaml_file_path, bec_test_config_file_path)
35
+
36
+ service_config = ServiceConfigModel(
37
+ redis={"host": redis_host, "port": redis_port},
38
+ file_writer={"base_path": str(bec_services_config_file_path.parent)},
39
+ acl={"env_file": str(acl_env_file)},
40
+ )
41
+ bec_services_config_file_path.write_text(
42
+ service_config.model_dump_json(indent=4), encoding="utf-8"
43
+ )
44
+
45
+ service_handler = ServiceHandler(
46
+ bec_path=bec_files_path, config_path=bec_services_config_file_path, interface="subprocess"
47
+ )
48
+ processes = None
49
+ try:
50
+ acl_connector = RedisConnector(redis_url)
51
+ try:
52
+ access_control = BECAccessDemo(acl_connector)
53
+ access_control.reset()
54
+ access_control.add_user()
55
+ access_control.add_admin()
56
+ access_control.set_default_non_admin()
57
+ finally:
58
+ acl_connector.shutdown()
59
+
60
+ processes = service_handler.start()
61
+ yield bec_services_config_file_path
62
+ finally:
63
+ if processes is not None:
64
+ service_handler.stop(processes)
65
+ restore_connector = RedisConnector(redis_url)
66
+ try:
67
+ BECAccessDemo(restore_connector).reset()
68
+ finally:
69
+ restore_connector.shutdown()
70
+
71
+
72
+ @pytest.mark.timeout(120)
73
+ def test_acl_admin_server_allows_default_user_scan(acl_enabled_bec_services):
74
+ admin_config = ServiceConfig(acl_enabled_bec_services)
75
+ admin_client = BECClient(admin_config, RedisConnector, forced=True, wait_for_server=True)
76
+ admin_client.start()
77
+ try:
78
+ admin_client.config.load_demo_config(force=True)
79
+ finally:
80
+ admin_client.shutdown()
81
+ admin_client._client._reset_singleton()
82
+
83
+ user_config = ServiceConfig(acl_enabled_bec_services, acl={"env_file": "", "user": "user"})
84
+ user_client = BECClient(user_config, RedisConnector, forced=True, wait_for_server=True)
85
+ user_client.start()
86
+ try:
87
+ user_client.queue.request_queue_reset()
88
+ user_client.queue.request_scan_continuation()
89
+ wait_for_empty_queue(user_client)
90
+ assert user_client.username == "user"
91
+
92
+ dev = user_client.device_manager.devices
93
+ status = user_client.scans.line_scan(
94
+ dev.samx, -0.1, 0.1, steps=3, exp_time=0.01, relative=False
95
+ )
96
+ status.wait(num_points=True, file_written=True)
97
+
98
+ assert status.scan.num_points == 3
99
+ finally:
100
+ user_client.shutdown()
101
+ user_client._client._reset_singleton()
102
+
103
+
104
+ @pytest.mark.timeout(120)
105
+ def test_acl_account_endpoint_allows_user_read_but_admin_only_write(acl_enabled_bec_services):
106
+ account_endpoint = MessageEndpoints.account()
107
+ initial_account_msg = messages.VariableMessage(value="initial-admin-account")
108
+ updated_account_msg = messages.VariableMessage(value="updated-admin-account")
109
+
110
+ user_config = ServiceConfig(acl_enabled_bec_services, acl={"env_file": "", "user": "user"})
111
+ user_client = BECClient(user_config, RedisConnector, forced=True, wait_for_server=True)
112
+ user_client.start()
113
+ try:
114
+ assert user_client.username == "user"
115
+ with user_client.acl.temporary_user(username="admin", token="admin"):
116
+ user_client._update_username()
117
+ assert user_client.username == "admin"
118
+ user_client.connector.xadd(account_endpoint, {"data": initial_account_msg})
119
+ assert user_client.connector.get_last(account_endpoint, "data") == initial_account_msg
120
+
121
+ user_client._update_username()
122
+ assert user_client.username == "user"
123
+ assert user_client.connector.get_last(account_endpoint, "data") == initial_account_msg
124
+ with pytest.raises(redis.exceptions.NoPermissionError):
125
+ user_client.connector.xadd(
126
+ account_endpoint, {"data": messages.VariableMessage(value="user-account")}
127
+ )
128
+
129
+ with user_client.acl.temporary_user(username="admin", token="admin"):
130
+ user_client._update_username()
131
+ assert user_client.username == "admin"
132
+ user_client.connector.xadd(account_endpoint, {"data": updated_account_msg})
133
+ assert user_client.connector.get_last(account_endpoint, "data") == updated_account_msg
134
+
135
+ user_client._update_username()
136
+ assert user_client.username == "user"
137
+ assert user_client.connector.get_last(account_endpoint, "data") == updated_account_msg
138
+ finally:
139
+ user_client.shutdown()
140
+ user_client._client._reset_singleton()
@@ -260,6 +260,47 @@ def test_scan_abort(bec_ipython_client_fixture: BECIPythonClient):
260
260
  assert scan_number_end == scan_number_start + 2
261
261
 
262
262
 
263
+ @pytest.mark.timeout(100)
264
+ def test_umv_ctrl_c_stops_motion(bec_ipython_client_fixture: BECIPythonClient):
265
+ def send_abort_after_delay():
266
+ time.sleep(1)
267
+ _thread.interrupt_main()
268
+
269
+ bec = bec_ipython_client_fixture
270
+ bec.metadata.update({"unit_test": "test_umv_ctrl_c_stops_motion"})
271
+ scans = bec.scans
272
+ dev = bec.device_manager.devices
273
+ original_velocity = dev.samx.velocity.get()
274
+ aborted_scan = False
275
+
276
+ try:
277
+ dev.samx.velocity.set(100).wait()
278
+ scans.umv(dev.samx, 0, relative=False)
279
+ dev.samx.velocity.set(1).wait()
280
+
281
+ threading.Thread(target=send_abort_after_delay, daemon=True).start()
282
+ try:
283
+ scans.umv(dev.samx, 40, relative=False)
284
+ except ScanInterruption:
285
+ aborted_scan = True
286
+
287
+ assert aborted_scan is True
288
+
289
+ timeout = time.time() + 10
290
+ while dev.samx.motor_is_moving.get() and time.time() < timeout:
291
+ time.sleep(0.1)
292
+
293
+ assert not dev.samx.motor_is_moving.get()
294
+ assert dev.samx.readback.get() < 40
295
+ finally:
296
+ if dev.samx.motor_is_moving.get():
297
+ dev.samx.stop()
298
+ timeout = time.time() + 10
299
+ while dev.samx.motor_is_moving.get() and time.time() < timeout:
300
+ time.sleep(0.1)
301
+ dev.samx.velocity.set(original_velocity).wait()
302
+
303
+
263
304
  @pytest.mark.timeout(100)
264
305
  def test_limit_error(bec_ipython_client_fixture):
265
306
  bec = bec_ipython_client_fixture