bec-ipython-client 4.1.6__tar.gz → 4.2.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 (46) hide show
  1. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/PKG-INFO +1 -1
  2. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/bec_startup.py +9 -42
  3. bec_ipython_client-4.2.0/bec_ipython_client/gui_client.py +66 -0
  4. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/main.py +24 -5
  5. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/pyproject.toml +1 -1
  6. bec_ipython_client-4.2.0/tests/client_tests/test_gui_client.py +180 -0
  7. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/.gitignore +0 -0
  8. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/__init__.py +0 -0
  9. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/beamline_mixin.py +0 -0
  10. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/bec_magics.py +0 -0
  11. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/callbacks/__init__.py +0 -0
  12. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/callbacks/device_progress.py +0 -0
  13. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/callbacks/ipython_live_updates.py +0 -0
  14. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/callbacks/live_table.py +0 -0
  15. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/callbacks/move_device.py +0 -0
  16. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/callbacks/utils.py +0 -0
  17. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/high_level_interfaces/__init__.py +0 -0
  18. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/high_level_interfaces/bec_hli.py +0 -0
  19. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/high_level_interfaces/spec_hli.py +0 -0
  20. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/plugins/SLS/__init__.py +0 -0
  21. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/plugins/SLS/sls_info.py +0 -0
  22. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/plugins/XTreme/__init__.py +0 -0
  23. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/plugins/XTreme/x-treme.py +0 -0
  24. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/plugins/__init__.py +0 -0
  25. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/plugins/flomni/flomni_config.yaml +0 -0
  26. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/prettytable.py +0 -0
  27. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/progressbar.py +0 -0
  28. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/bec_ipython_client/signals.py +0 -0
  29. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/demo.py +0 -0
  30. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/tests/client_tests/conftest.py +0 -0
  31. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/tests/client_tests/test_beamline_mixins.py +0 -0
  32. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/tests/client_tests/test_bec_client.py +0 -0
  33. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/tests/client_tests/test_device_progress.py +0 -0
  34. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/tests/client_tests/test_ipython_live_updates.py +0 -0
  35. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/tests/client_tests/test_live_table.py +0 -0
  36. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/tests/client_tests/test_move_callback.py +0 -0
  37. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/tests/client_tests/test_pretty_table.py +0 -0
  38. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/tests/client_tests/test_signals.py +0 -0
  39. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/tests/conftest.py +0 -0
  40. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/tests/end-2-end/_ensure_requirements_container.py +0 -0
  41. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/tests/end-2-end/test_acl_e2e.py +0 -0
  42. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/tests/end-2-end/test_actors_e2e.py +0 -0
  43. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/tests/end-2-end/test_procedures_e2e.py +0 -0
  44. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/tests/end-2-end/test_scan_workflows_lib_e2e.py +0 -0
  45. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/tests/end-2-end/test_scans_e2e.py +0 -0
  46. {bec_ipython_client-4.1.6 → bec_ipython_client-4.2.0}/tests/end-2-end/test_scans_lib_e2e.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bec_ipython_client
3
- Version: 4.1.6
3
+ Version: 4.2.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
@@ -13,46 +13,11 @@ from bec_lib.redis_connector import RedisConnector as _RedisConnector
13
13
  logger = _bec_logger.logger
14
14
 
15
15
 
16
- class _LazyBECGuiClient:
17
- """Defer BEC Widgets import while preserving the interactive ``gui`` object."""
18
-
19
- def __init__(self, gui_id: str | None = None):
20
- self._gui_id = gui_id
21
- self._client = None
22
-
23
- def _materialize(self):
24
- if self._client is None:
25
- try:
26
- from bec_widgets.cli.client_utils import BECGuiClient
27
- except ImportError:
28
- logger.warning("BEC Widgets is not available; skipping GUI startup.")
29
- bec.gui = None
30
- globals().pop("gui", None)
31
- raise
32
-
33
- self._client = BECGuiClient()
34
- if self._gui_id:
35
- self._client.connect_to_gui_server(self._gui_id)
36
- bec.gui = self._client
37
- globals()["gui"] = self._client
38
- return self._client
39
-
40
- def __getattr__(self, name):
41
- return getattr(self._materialize(), name)
42
-
43
- def __setattr__(self, name, value):
44
- if name.startswith("_"):
45
- return super().__setattr__(name, value)
46
- return setattr(self._materialize(), name, value)
47
-
48
- def __repr__(self) -> str:
49
- if self._client is None:
50
- return "LazyBECGuiClient(uninitialized)"
51
- return repr(self._client)
52
-
53
-
54
16
  bec = _BECIPythonClient(
55
- _main_dict["config"], _RedisConnector, wait_for_server=_main_dict["wait_for_server"]
17
+ _main_dict["config"],
18
+ _RedisConnector,
19
+ wait_for_server=_main_dict["wait_for_server"],
20
+ gui_id=_main_dict["args"].gui_id,
56
21
  )
57
22
  _main_dict["bec"] = bec
58
23
 
@@ -66,13 +31,15 @@ except Exception:
66
31
  sys.excepthook(*sys.exc_info())
67
32
  else:
68
33
  if bec.started:
69
- gui = bec.gui = _LazyBECGuiClient(gui_id=_main_dict["args"].gui_id)
34
+ gui = bec._gui # pylint: disable=protected-access
70
35
  if not _main_dict["args"].nogui:
71
36
  try:
72
37
  gui.show()
73
38
  except ImportError:
74
- logger.warning("BEC Widgets is not available")
75
- pass
39
+ logger.warning(
40
+ "BEC Widgets is unavailable; skipping GUI startup. "
41
+ "Install bec-widgets in this Python environment to use the GUI."
42
+ )
76
43
 
77
44
  _available_plugins = plugin_helper.get_ipython_client_startup_plugins(state="post")
78
45
  if _available_plugins:
@@ -0,0 +1,66 @@
1
+ """Deferred GUI access for the interactive BEC client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from bec_widgets.cli.client_utils import BECGuiClient
9
+
10
+
11
+ class LazyBECGuiClient:
12
+ """Import and create the GUI client on first use, independently of the shell namespace."""
13
+
14
+ def __init__(self, gui_id: str | None = None) -> None:
15
+ self._gui_id = gui_id
16
+ self._client: BECGuiClient | None = None
17
+
18
+ def get_client(self) -> BECGuiClient:
19
+ """Return the GUI client, importing BEC Widgets when first requested.
20
+
21
+ Returns:
22
+ BECGuiClient: The initialized GUI client.
23
+
24
+ Raises:
25
+ ImportError: If BEC Widgets or one of its dependencies cannot be imported.
26
+ """
27
+ if self._client is None:
28
+ try:
29
+ # Import only when GUI access is requested.
30
+ # pylint: disable=import-outside-toplevel
31
+ from bec_widgets.cli.client_utils import BECGuiClient
32
+ except ImportError as exc:
33
+ raise ImportError(
34
+ "BEC Widgets is required to use the GUI. Install bec-widgets in the "
35
+ f"Python environment running this client. Import failed: {exc}"
36
+ ) from exc
37
+
38
+ client = BECGuiClient()
39
+ if self._gui_id:
40
+ client.connect_to_gui_server(self._gui_id)
41
+ self._client = client
42
+ return self._client
43
+
44
+ def close(self) -> None:
45
+ """Close an initialized GUI client without creating one during shutdown."""
46
+ if self._client is not None:
47
+ self._client.close()
48
+
49
+ def __getattr__(self, name: str) -> Any:
50
+ return getattr(self.get_client(), name)
51
+
52
+ def __dir__(self) -> list[str]:
53
+ if self._client is None:
54
+ return super().__dir__()
55
+ return dir(self._client)
56
+
57
+ def __setattr__(self, name: str, value: Any) -> None:
58
+ if name.startswith("_"):
59
+ super().__setattr__(name, value)
60
+ else:
61
+ setattr(self.get_client(), name, value)
62
+
63
+ def __repr__(self) -> str:
64
+ if self._client is None:
65
+ return "LazyBECGuiClient(uninitialized)"
66
+ return repr(self._client)
@@ -6,7 +6,7 @@ import functools
6
6
  import os
7
7
  import sys
8
8
  import traceback
9
- from typing import Iterable, Literal, Tuple
9
+ from typing import TYPE_CHECKING, Iterable, Literal, Tuple
10
10
 
11
11
  import IPython
12
12
  import redis
@@ -21,6 +21,7 @@ from rich.text import Text
21
21
  from bec_ipython_client.beamline_mixin import BeamlineMixin
22
22
  from bec_ipython_client.bec_magics import BECMagics
23
23
  from bec_ipython_client.callbacks.ipython_live_updates import IPythonLiveUpdates
24
+ from bec_ipython_client.gui_client import LazyBECGuiClient
24
25
  from bec_ipython_client.signals import OperationMode, ScanInterruption, SigintHandler
25
26
  from bec_lib import messages, plugin_helper
26
27
  from bec_lib.alarm_handler import AlarmBase
@@ -34,6 +35,9 @@ from bec_lib.redis_connector import MessageObject, RedisConnector
34
35
  from bec_lib.service_config import ServiceConfig
35
36
  from bec_lib.utils.pydantic_pretty_print import pretty_print_pydantic_validation_error
36
37
 
38
+ if TYPE_CHECKING:
39
+ from bec_widgets.cli.client_utils import BECGuiClient
40
+
37
41
  logger = bec_logger.logger
38
42
 
39
43
 
@@ -66,6 +70,7 @@ class BECIPythonClient:
66
70
  wait_for_server=True,
67
71
  forced=False,
68
72
  mode: OperationMode = OperationMode.Normal,
73
+ gui_id: str | None = None,
69
74
  ) -> None:
70
75
  self._client = CLIBECClient(
71
76
  config,
@@ -85,12 +90,24 @@ class BECIPythonClient:
85
90
  self._exit_event = None
86
91
  self._exit_handler_thread = None
87
92
  self._live_updates = None
88
- self.gui = None
93
+ self._gui = LazyBECGuiClient(gui_id=gui_id)
89
94
  self._client.callbacks.register(
90
95
  event_type=EventType.NAMESPACE_UPDATE, callback=self._update_namespace_callback
91
96
  )
92
97
  self._alarm_history = collections.deque(maxlen=100)
93
98
 
99
+ @property
100
+ def gui(self) -> BECGuiClient:
101
+ """Access the GUI client, loading BEC Widgets on first access.
102
+
103
+ Returns:
104
+ BECGuiClient: The initialized GUI client.
105
+
106
+ Raises:
107
+ ImportError: If BEC Widgets or one of its dependencies cannot be imported.
108
+ """
109
+ return self._gui.get_client()
110
+
94
111
  def __getattr__(self, name):
95
112
  return getattr(self._client, name)
96
113
 
@@ -218,9 +235,11 @@ class BECIPythonClient:
218
235
  def shutdown(self, per_thread_timeout_s: float | None = None):
219
236
  """shutdown the client and all its components"""
220
237
  try:
221
- self.gui.close()
222
- except AttributeError:
223
- pass
238
+ # call close on the lazy GUI proxy, which will close the
239
+ # GUI client if it was initialized, or do nothing if it wasn't
240
+ self._gui.close()
241
+ except Exception as exc: # pylint: disable=broad-except
242
+ logger.error(f"Error closing GUI client: {exc}")
224
243
  self._client.shutdown(per_thread_timeout_s)
225
244
  logger.success("done")
226
245
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bec_ipython_client"
7
- version = "4.1.6"
7
+ version = "4.2.0"
8
8
  description = "BEC IPython client"
9
9
  requires-python = ">=3.11"
10
10
  classifiers = [
@@ -0,0 +1,180 @@
1
+ """Regression coverage for deferred GUI imports and shell shutdown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ import runpy
7
+ import sys
8
+ from pathlib import Path
9
+ from types import ModuleType, SimpleNamespace
10
+ from unittest import mock
11
+
12
+ import pytest
13
+
14
+ from bec_ipython_client import BECIPythonClient, main
15
+ from bec_ipython_client.gui_client import LazyBECGuiClient
16
+
17
+
18
+ @pytest.fixture
19
+ def widgets_unavailable(monkeypatch):
20
+ original_import = builtins.__import__
21
+ attempts = []
22
+
23
+ def import_without_widgets(name, *args, **kwargs):
24
+ if name.startswith("bec_widgets"):
25
+ attempts.append(name)
26
+ raise ModuleNotFoundError("No module named 'bec_widgets'")
27
+ return original_import(name, *args, **kwargs)
28
+
29
+ monkeypatch.setattr(builtins, "__import__", import_without_widgets)
30
+ return attempts
31
+
32
+
33
+ @pytest.fixture
34
+ def gui_factory(monkeypatch):
35
+ module = ModuleType("bec_widgets.cli.client_utils")
36
+ factory = mock.Mock()
37
+ module.BECGuiClient = factory
38
+ monkeypatch.setitem(sys.modules, module.__name__, module)
39
+ return factory
40
+
41
+
42
+ @pytest.fixture
43
+ def client():
44
+ # Exercise the real GUI property and shutdown without connecting to Redis.
45
+ instance = BECIPythonClient.__new__(BECIPythonClient)
46
+ instance.__dict__.update(
47
+ _client=mock.Mock(),
48
+ _gui=LazyBECGuiClient(),
49
+ started=True,
50
+ start=mock.Mock(),
51
+ _ip=mock.Mock(),
52
+ )
53
+ return instance
54
+
55
+
56
+ @pytest.mark.parametrize("nogui", [True, False])
57
+ def test_shutdown_after_startup_namespace_cleanup(nogui, widgets_unavailable, monkeypatch, client):
58
+ monkeypatch.setattr(main, "BECIPythonClient", mock.Mock(return_value=client))
59
+ monkeypatch.setattr(
60
+ "bec_lib.plugin_helper.get_ipython_client_startup_plugins", mock.Mock(return_value={})
61
+ )
62
+ with mock.patch.dict(
63
+ main.main_dict,
64
+ {
65
+ "config": None,
66
+ "wait_for_server": False,
67
+ "args": SimpleNamespace(nogui=nogui, gui_id=None),
68
+ "startup_file": None,
69
+ },
70
+ clear=True,
71
+ ):
72
+ namespace = runpy.run_path(str(Path(main.__file__).with_name("bec_startup.py")))
73
+ assert len(widgets_unavailable) == (0 if nogui else 1)
74
+ assert namespace["gui"] is client._gui
75
+ namespace.clear()
76
+ client.shutdown(per_thread_timeout_s=1)
77
+ client._client.shutdown.assert_called_once_with(1)
78
+ assert len(widgets_unavailable) == (0 if nogui else 1)
79
+
80
+
81
+ @pytest.mark.parametrize("gui_id", [None, "existing-gui"])
82
+ def test_gui_can_be_started_later_and_is_created_once(gui_factory, gui_id):
83
+ gui = LazyBECGuiClient(gui_id=gui_id)
84
+ gui_factory.assert_not_called()
85
+ assert repr(gui) == "LazyBECGuiClient(uninitialized)"
86
+ gui.close()
87
+ gui_factory.assert_not_called()
88
+
89
+ gui.show()
90
+ gui.show()
91
+ gui.set_rpc_timeout(5)
92
+ gui.some_setting = "value"
93
+ gui.close()
94
+
95
+ gui_factory.assert_called_once_with()
96
+ real_gui = gui_factory.return_value
97
+ assert real_gui.show.call_count == 2
98
+ real_gui.set_rpc_timeout.assert_called_once_with(5)
99
+ assert real_gui.some_setting == "value"
100
+ real_gui.close.assert_called_once_with()
101
+ if gui_id:
102
+ real_gui.connect_to_gui_server.assert_called_once_with(gui_id)
103
+ else:
104
+ real_gui.connect_to_gui_server.assert_not_called()
105
+
106
+
107
+ def test_missing_widgets_on_explicit_use_does_not_break_shutdown(widgets_unavailable):
108
+ gui = LazyBECGuiClient()
109
+ with pytest.raises(ImportError, match="Install bec-widgets"):
110
+ gui.show()
111
+ gui.close()
112
+ assert len(widgets_unavailable) == 1
113
+
114
+
115
+ def test_gui_completion_does_not_import_widgets_before_initialization(widgets_unavailable):
116
+ gui = LazyBECGuiClient()
117
+
118
+ assert "close" in dir(gui)
119
+ assert widgets_unavailable == []
120
+
121
+
122
+ def test_gui_completion_exposes_initialized_client_methods(gui_factory):
123
+ real_client = gui_factory.return_value
124
+ real_client.show = mock.Mock()
125
+ real_client.set_rpc_timeout = mock.Mock()
126
+ gui = LazyBECGuiClient()
127
+
128
+ gui.show()
129
+
130
+ assert dir(gui) == dir(real_client)
131
+ assert "show" in dir(gui)
132
+ assert "set_rpc_timeout" in dir(gui)
133
+ gui_factory.assert_called_once_with()
134
+
135
+
136
+ def test_failed_gui_connection_can_be_retried(gui_factory):
137
+ failed_client = mock.Mock()
138
+ failed_client.connect_to_gui_server.side_effect = RuntimeError("Connection failed")
139
+ connected_client = mock.Mock()
140
+ gui_factory.side_effect = [failed_client, connected_client]
141
+ gui = LazyBECGuiClient(gui_id="existing-gui")
142
+
143
+ with pytest.raises(RuntimeError, match="Connection failed"):
144
+ gui.get_client()
145
+
146
+ assert gui.get_client() is connected_client
147
+ assert gui.get_client() is connected_client
148
+ assert gui_factory.call_count == 2
149
+ failed_client.connect_to_gui_server.assert_called_once_with("existing-gui")
150
+ connected_client.connect_to_gui_server.assert_called_once_with("existing-gui")
151
+
152
+
153
+ def test_gui_property_reports_missing_widgets(client, widgets_unavailable):
154
+ with pytest.raises(ImportError, match="Install bec-widgets") as exc:
155
+ client.gui
156
+ assert isinstance(exc.value.__cause__, ModuleNotFoundError)
157
+ client.shutdown()
158
+ client._client.shutdown.assert_called_once_with(None)
159
+ assert len(widgets_unavailable) == 1
160
+
161
+
162
+ def test_gui_property_returns_real_client_and_caches_it(client, gui_factory):
163
+ gui_factory.assert_not_called()
164
+ assert client.gui is gui_factory.return_value
165
+ assert client.gui is gui_factory.return_value
166
+ client.shutdown()
167
+ gui_factory.assert_called_once_with()
168
+ gui_factory.return_value.close.assert_called_once_with()
169
+
170
+
171
+ def test_gui_cleanup_failure_does_not_prevent_core_shutdown(client, gui_factory):
172
+ real_gui = client.gui
173
+ real_gui.close.side_effect = RuntimeError("GUI cleanup failed")
174
+
175
+ with mock.patch.object(main, "logger") as logger:
176
+ client.shutdown(per_thread_timeout_s=1)
177
+
178
+ real_gui.close.assert_called_once_with()
179
+ client._client.shutdown.assert_called_once_with(1)
180
+ logger.error.assert_called_once_with("Error closing GUI client: GUI cleanup failed")