From 48ae950d57b454307ce409e2511f7b7adf3cfc6b Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 13 Jun 2024 15:05:29 +0200 Subject: [PATCH] feat(widgets): added vscode widget --- bec_widgets/cli/client.py | 4 ++ bec_widgets/widgets/vscode/__init__.py | 0 bec_widgets/widgets/vscode/vscode.py | 86 ++++++++++++++++++++++++++ bec_widgets/widgets/website/website.py | 11 +++- tests/unit_tests/test_vscode_widget.py | 61 ++++++++++++++++++ 5 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 bec_widgets/widgets/vscode/__init__.py create mode 100644 bec_widgets/widgets/vscode/vscode.py create mode 100644 tests/unit_tests/test_vscode_widget.py diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 0ac3ea83..2428fbe1 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -19,6 +19,7 @@ class Widgets(str, enum.Enum): BECFigure = "BECFigure" SpiralProgressBar = "SpiralProgressBar" TextBox = "TextBox" + VSCodeEditor = "VSCodeEditor" WebsiteWidget = "WebsiteWidget" @@ -2049,6 +2050,9 @@ class TextBox(RPCBase): """ +class VSCodeEditor(RPCBase): ... + + class WebsiteWidget(RPCBase): @rpc_call def set_url(self, url: str) -> None: diff --git a/bec_widgets/widgets/vscode/__init__.py b/bec_widgets/widgets/vscode/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/vscode/vscode.py b/bec_widgets/widgets/vscode/vscode.py new file mode 100644 index 00000000..13a2a72c --- /dev/null +++ b/bec_widgets/widgets/vscode/vscode.py @@ -0,0 +1,86 @@ +import os +import select +import shlex +import signal +import subprocess +import sys + +from bec_widgets.widgets.website.website import WebsiteWidget + + +class VSCodeEditor(WebsiteWidget): + """ + A widget to display the VSCode editor. + """ + + token = "bec" + host = "127.0.0.1" + port = 7000 + + USER_ACCESS = [] + + def __init__(self, parent=None, config=None, client=None, gui_id=None): + + self.process = None + self._url = f"http://{self.host}:{self.port}?tkn={self.token}" + super().__init__(parent=parent, config=config, client=client, gui_id=gui_id) + self.start_server() + + def start_server(self): + """ + Start the server. + + This method starts the server for the VSCode editor in a subprocess. + """ + + cmd = shlex.split( + f"code serve-web --port {self.port} --connection-token={self.token} --accept-server-license-terms" + ) + self.process = subprocess.Popen( + cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, preexec_fn=os.setsid + ) + + os.set_blocking(self.process.stdout.fileno(), False) + while self.process.poll() is None: + readylist, _, _ = select.select([self.process.stdout], [], [], 1) + if self.process.stdout in readylist: + output = self.process.stdout.read(1024) + if output and f"available at {self._url}" in output: + break + self.set_url(self._url) + + def closeEvent(self, event): + """ + Hook for the close event to terminate the server. + """ + self.cleanup_vscode() + super().closeEvent(event) + + def cleanup_vscode(self): + """ + Cleanup the VSCode editor. + """ + if not self.process: + return + os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) + self.process.wait() + + def cleanup(self): + """ + Cleanup the widget. This method is called from the dock area when the widget is removed. + """ + self.cleanup_vscode() + return super().cleanup() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = VSCodeEditor() + widget.show() + app.exec_() + widget.bec_dispatcher.disconnect_all() + widget.client.shutdown() diff --git a/bec_widgets/widgets/website/website.py b/bec_widgets/widgets/website/website.py index a69a2dcf..fbc07f37 100644 --- a/bec_widgets/widgets/website/website.py +++ b/bec_widgets/widgets/website/website.py @@ -1,10 +1,19 @@ -from qtpy.QtCore import QUrl +from qtpy.QtCore import QUrl, qInstallMessageHandler from qtpy.QtWebEngineWidgets import QWebEngineView from qtpy.QtWidgets import QApplication from bec_widgets.utils import BECConnector +def suppress_qt_messages(type_, context, msg): + if context.category in ["js", "default"]: + return + print(msg) + + +qInstallMessageHandler(suppress_qt_messages) + + class WebsiteWidget(BECConnector, QWebEngineView): """ A simple widget to display a website diff --git a/tests/unit_tests/test_vscode_widget.py b/tests/unit_tests/test_vscode_widget.py new file mode 100644 index 00000000..bdd30093 --- /dev/null +++ b/tests/unit_tests/test_vscode_widget.py @@ -0,0 +1,61 @@ +import os +import shlex +import subprocess +from unittest import mock + +import pytest + +from bec_widgets.widgets.vscode.vscode import VSCodeEditor + +from .client_mocks import mocked_client + + +@pytest.fixture +def vscode_widget(qtbot, mocked_client): + with mock.patch("bec_widgets.widgets.vscode.vscode.subprocess.Popen") as mock_popen: + widget = VSCodeEditor(client=mocked_client) + yield widget + + +def test_vscode_widget(qtbot, vscode_widget): + assert vscode_widget.process is not None + assert vscode_widget._url == "http://127.0.0.1:7000?tkn=bec" + + +def test_start_server(qtbot, mocked_client): + + with mock.patch("bec_widgets.widgets.vscode.vscode.subprocess.Popen") as mock_popen: + mock_process = mock.Mock() + mock_process.stdout.fileno.return_value = 1 + mock_process.poll.return_value = None + mock_process.stdout.read.return_value = ( + f"available at http://{VSCodeEditor.host}:{VSCodeEditor.port}?tkn={VSCodeEditor.token}" + ) + mock_popen.return_value = mock_process + + widget = VSCodeEditor(client=mocked_client) + + mock_popen.assert_called_once_with( + shlex.split( + f"code serve-web --port {widget.port} --connection-token={widget.token} --accept-server-license-terms" + ), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + preexec_fn=os.setsid, + ) + + +def test_close_event(qtbot, vscode_widget): + with mock.patch("bec_widgets.widgets.vscode.vscode.os.killpg") as mock_killpg: + with mock.patch("bec_widgets.widgets.vscode.vscode.os.getpgid") as mock_getpgid: + with mock.patch( + "bec_widgets.widgets.website.website.WebsiteWidget.closeEvent" + ) as mock_close_event: + mock_getpgid.return_value = 123 + vscode_widget.process = mock.Mock() + vscode_widget.process.pid = 123 + vscode_widget.closeEvent(None) + mock_killpg.assert_called_once_with(123, 15) + vscode_widget.process.wait.assert_called_once() + mock_close_event.assert_called_once()