From 2047e484d5a4b2f5ea494a1e49035b35b1bbde35 Mon Sep 17 00:00:00 2001 From: Mathias Guijarro Date: Mon, 11 Nov 2024 20:19:44 +0100 Subject: [PATCH] feat: asynchronous .start() for GUI --- bec_widgets/cli/client_utils.py | 55 ++++++++++++++++++++++----- tests/unit_tests/test_client_utils.py | 32 +++++++++++----- 2 files changed, 68 insertions(+), 19 deletions(-) diff --git a/bec_widgets/cli/client_utils.py b/bec_widgets/cli/client_utils.py index 771d7407..eb4fb22e 100644 --- a/bec_widgets/cli/client_utils.py +++ b/bec_widgets/cli/client_utils.py @@ -92,11 +92,11 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger process will not be captured. """ # pylint: disable=subprocess-run-check - command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__] + command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__, "--hide"] if config: if isinstance(config, dict): config = json.dumps(config) - command.extend(["--config", config]) + command.extend(["--config", str(config)]) env_dict = os.environ.copy() env_dict["PYTHONUNBUFFERED"] = "1" @@ -126,9 +126,17 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger return process, process_output_processing_thread +class RepeatTimer(threading.Timer): + def run(self): + while not self.finished.wait(self.interval): + self.function(*self.args, **self.kwargs) + + class BECGuiClientMixin: def __init__(self, **kwargs) -> None: super().__init__(**kwargs) + self._gui_started_timer = None + self._gui_started_event = threading.Event() self._process = None self._process_output_processing_thread = None self.auto_updates = self._get_update_script() @@ -182,29 +190,57 @@ class BECGuiClientMixin: return self.auto_updates.msg_queue.put(msg) - def show(self) -> None: + def _gui_post_startup(self): + if self.auto_updates is None: + AutoUpdates.create_default_dock = True + AutoUpdates.enabled = True + self.auto_updates = AutoUpdates(gui=self) + if self.auto_updates.create_default_dock: + self.auto_updates.start_default_dock() + fig = self.auto_updates.get_default_figure() + self._gui_started_event.set() + self.show_all() + + def start_server(self, wait=False) -> None: """ - Show the figure. + Start the GUI server, and execute callback when it is launched """ if self._process is None or self._process.poll() is not None: + logger.success("GUI starting...") + self._gui_started_event.clear() self._start_update_script() self._process, self._process_output_processing_thread = _start_plot_process( self._gui_id, self.__class__, self._client._service_config.config, logger=logger ) - while not self.gui_is_alive(): - print("Waiting for GUI to start...") - time.sleep(1) - logger.success(f"GUI started with id: {self._gui_id}") + + def gui_started_callback(callback): + try: + if callable(callback): + callback() + finally: + threading.current_thread().cancel() + + self._gui_started_timer = RepeatTimer( + 1, lambda: self.gui_is_alive() and gui_started_callback(self._gui_post_startup) + ) + self._gui_started_timer.start() + + if wait: + self._gui_started_event.wait() def close(self) -> None: """ Close the gui window. """ + if self._gui_started_timer is not None: + self._gui_started_timer.cancel() + self._gui_started_timer.join() + if self._process is None: return - self._client.shutdown() if self._process: + logger.success("Stopping GUI...") self._process.terminate() if self._process_output_processing_thread: self._process_output_processing_thread.join() @@ -212,6 +248,7 @@ class BECGuiClientMixin: self._process = None if self.auto_updates is not None: self.auto_updates.shutdown() + self.auto_updates = None class RPCResponseTimeoutError(Exception): diff --git a/tests/unit_tests/test_client_utils.py b/tests/unit_tests/test_client_utils.py index 49ff7dcc..1b53fdc2 100644 --- a/tests/unit_tests/test_client_utils.py +++ b/tests/unit_tests/test_client_utils.py @@ -1,3 +1,4 @@ +from contextlib import contextmanager from unittest import mock import pytest @@ -40,7 +41,7 @@ def test_rpc_call_accepts_device_as_input(cli_figure): def test_client_utils_start_plot_process(config, call_config): with mock.patch("bec_widgets.cli.client_utils.subprocess.Popen") as mock_popen: _start_plot_process("gui_id", BECFigure, config) - command = ["bec-gui-server", "--id", "gui_id", "--gui_class", "BECFigure"] + command = ["bec-gui-server", "--id", "gui_id", "--gui_class", "BECFigure", "--hide"] if call_config: command.extend(["--config", call_config]) mock_popen.assert_called_once_with( @@ -59,17 +60,28 @@ def test_client_utils_passes_client_config_to_server(bec_dispatcher): changes to the client config (either through config files or plugins) are reflected in the server. """ - mixin = BECGuiClientMixin() - mixin._client = bec_dispatcher.client - mixin._gui_id = "gui_id" - mixin.gui_is_alive = mock.MagicMock() - mixin.gui_is_alive.side_effect = [True] - with mock.patch("bec_widgets.cli.client_utils._start_plot_process") as mock_start_plot: - with mock.patch.object(mixin, "_start_update_script") as mock_start_update: + @contextmanager + def bec_client_mixin(): + mixin = BECGuiClientMixin() + mixin._client = bec_dispatcher.client + mixin._gui_id = "gui_id" + mixin.gui_is_alive = mock.MagicMock() + mixin.gui_is_alive.side_effect = [True] + + try: + with mock.patch.object(mixin, "_start_update_script"): + yield mixin + finally: + mixin.close() + + with bec_client_mixin() as mixin: + with mock.patch("bec_widgets.cli.client_utils._start_plot_process") as mock_start_plot: mock_start_plot.return_value = [mock.MagicMock(), mock.MagicMock()] - mixin.show() + mixin.start_server( + wait=False + ) # the started event will not be set, wait=True would block forever mock_start_plot.assert_called_once_with( "gui_id", BECGuiClientMixin, mixin._client._service_config.config, logger=mock.ANY ) - mock_start_update.assert_called_once() + mixin._start_update_script.assert_called_once()