From 21cbb6d17c3da5e785e71ee45d7baa81e931de9e Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 10 Sep 2026 16:02:38 +0200 Subject: [PATCH] feat(gui): add display diagnostics --- bec_widgets/cli/client_utils.py | 15 +++++ bec_widgets/utils/display_info.py | 94 +++++++++++++++++++++++++++ bec_widgets/utils/rpc_server.py | 9 ++- tests/end-2-end/test_display_info.py | 17 +++++ tests/unit_tests/test_client_utils.py | 28 ++++++++ tests/unit_tests/test_display_info.py | 50 ++++++++++++++ tests/unit_tests/test_rpc_server.py | 6 ++ 7 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 bec_widgets/utils/display_info.py create mode 100644 tests/end-2-end/test_display_info.py create mode 100644 tests/unit_tests/test_display_info.py diff --git a/bec_widgets/cli/client_utils.py b/bec_widgets/cli/client_utils.py index 0dbf8fc9..101a1f37 100644 --- a/bec_widgets/cli/client_utils.py +++ b/bec_widgets/cli/client_utils.py @@ -442,6 +442,21 @@ class BECGuiClient(RPCBase): return self._raise_all() return self._start(wait=wait) + def get_display_info(self) -> dict: + """Return display diagnostics from the running GUI server process. + + Includes the actual Qt platform plugin, Qt paths, selected environment variables, + and window visibility/focus state. Desktop environment variables are hints, not + authoritative identification of a Wayland compositor. Does not start the GUI. + + Returns: + dict: GUI server display diagnostics, suitable for ``pprint`` or JSON export. + """ + # System diagnostics do not depend on this client's local widget-startup event. + if not self._gui_is_alive(): + raise RuntimeError("GUI is not alive") + return self.launcher._run_rpc("system.get_display_info") + def change_theme(self, theme: Literal["light", "dark"] | None = None) -> None: """ Apply a GUI theme or toggle between dark and light. diff --git a/bec_widgets/utils/display_info.py b/bec_widgets/utils/display_info.py new file mode 100644 index 00000000..c37829e0 --- /dev/null +++ b/bec_widgets/utils/display_info.py @@ -0,0 +1,94 @@ +"""Read-only Qt display diagnostics collected inside the GUI process.""" + +from __future__ import annotations + +import os +import sys +from importlib.metadata import version + +from qtpy import API_NAME +from qtpy.QtCore import qVersion +from qtpy.QtWidgets import QApplication + +import bec_widgets + +DISPLAY_ENVIRONMENT_VARIABLES = ( + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "XDG_SESSION_DESKTOP", + "DESKTOP_SESSION", + "DISPLAY", + "WAYLAND_DISPLAY", + "QT_API", + "QT_QPA_PLATFORM", + "QT_QPA_PLATFORM_PLUGIN_PATH", + "QT_PLUGIN_PATH", + "QT_QPA_PLATFORMTHEME", + "QT_QPA_GENERIC_PLUGINS", + "QT_STYLE_OVERRIDE", + "QT_WAYLAND_SHELL_INTEGRATION", + "QT_WAYLAND_DISABLE_WINDOWDECORATION", + "LD_LIBRARY_PATH", + "LD_PRELOAD", +) + + +def get_display_info() -> dict: + """Return the GUI process's loaded Qt backend, environment hints, and window states. + + Must run in the Qt GUI thread. Environment variables describe the inherited session; + ``qt_platform`` identifies the platform plugin actually loaded by this application. + Qt has no portable API for identifying the Wayland compositor. + + Returns: + dict: JSON-serializable diagnostics without modifying any window or environment setting. + """ + app = QApplication.instance() + if app is None: + raise RuntimeError("Display diagnostics require a running QApplication.") + + windows = [] + for widget in app.topLevelWidgets(): + handle = widget.windowHandle() + # Ignore internal widgets without a native window; do not create one to inspect it. + if handle is None: + continue + windows.append( + { + "object_name": widget.objectName(), + "title": widget.windowTitle(), + "visible": widget.isVisible(), + "minimized": widget.isMinimized(), + "maximized": widget.isMaximized(), + "full_screen": widget.isFullScreen(), + "active": widget.isActiveWindow(), + "exposed": handle.isExposed(), + "flags": str(widget.windowFlags()), + "geometry": list(widget.geometry().getRect()), + "screen": handle.screen().name() if handle.screen() is not None else None, + } + ) + + return { + "pid": os.getpid(), + "os": sys.platform, + "python_executable": sys.executable, + "bec_widgets_version": version("bec_widgets"), + "bec_widgets_path": bec_widgets.__file__, + "qt_version": qVersion(), + "qt_binding": API_NAME, + "qt_platform": app.platformName(), + "qt_library_paths": app.libraryPaths(), + "qt_style": app.style().objectName(), + "environment": {name: os.environ.get(name) for name in DISPLAY_ENVIRONMENT_VARIABLES}, + "screens": [ + { + "name": screen.name(), + "geometry": list(screen.geometry().getRect()), + "available_geometry": list(screen.availableGeometry().getRect()), + "device_pixel_ratio": screen.devicePixelRatio(), + } + for screen in app.screens() + ], + "windows": sorted(windows, key=lambda window: (window["object_name"], window["title"])), + } diff --git a/bec_widgets/utils/rpc_server.py b/bec_widgets/utils/rpc_server.py index eff7798d..faff1d1d 100644 --- a/bec_widgets/utils/rpc_server.py +++ b/bec_widgets/utils/rpc_server.py @@ -18,6 +18,7 @@ from redis.exceptions import RedisError from bec_widgets.utils.bec_connector import BECConnector from bec_widgets.utils.bec_dispatcher import BECDispatcher from bec_widgets.utils.container_utils import WidgetContainerUtils +from bec_widgets.utils.display_info import get_display_info from bec_widgets.utils.error_popups import ErrorPopupUtility from bec_widgets.utils.rpc_logging import elapsed_seconds, format_elapsed from bec_widgets.utils.rpc_register import RPCRegister @@ -319,8 +320,14 @@ class RPCServer: return self._launch_dock_area(*args, **kwargs) if method == "system.shutdown": return self._shutdown_gui_server() + if method == "system.get_display_info": + return get_display_info() if method == "system.list_capabilities": - return {"system.launch_dock_area": True, "system.shutdown": True} + return { + "system.launch_dock_area": True, + "system.shutdown": True, + "system.get_display_info": True, + } raise ValueError(f"Unknown system RPC method: {method}") @staticmethod diff --git a/tests/end-2-end/test_display_info.py b/tests/end-2-end/test_display_info.py new file mode 100644 index 00000000..0da7ea34 --- /dev/null +++ b/tests/end-2-end/test_display_info.py @@ -0,0 +1,17 @@ +"""Display diagnostics from a fresh client attached to an existing GUI server.""" + +from bec_widgets.cli.client_utils import BECGuiClient + + +def test_display_info_from_fresh_client(connected_client_gui_obj): + server_owner = connected_client_gui_obj + attached = BECGuiClient(gui_id=server_owner._gui_id) + assert not attached._gui_started_event.is_set() + assert attached._process is None + + info = attached.get_display_info() + + assert info["pid"] == server_owner._process.pid + assert info["qt_platform"] + assert not attached._gui_started_event.is_set() + assert attached._process is None diff --git a/tests/unit_tests/test_client_utils.py b/tests/unit_tests/test_client_utils.py index 23200a25..c34fb53a 100644 --- a/tests/unit_tests/test_client_utils.py +++ b/tests/unit_tests/test_client_utils.py @@ -135,6 +135,34 @@ def _no_wait_for_server(_client): yield +@pytest.mark.parametrize("startup_completed", [False, True]) +def test_client_display_info_comes_from_server_without_starting_gui(startup_completed): + gui = BECGuiClient() + if startup_completed: + gui._gui_started_event.set() + with ( + mock.patch.object(BECGuiClient, "launcher", new_callable=mock.PropertyMock) as prop, + mock.patch.object(gui, "_gui_is_alive", return_value=True), + mock.patch.object(gui, "_start") as start, + ): + prop.return_value._run_rpc.return_value = {"qt_platform": "wayland"} + assert gui.get_display_info() == {"qt_platform": "wayland"} + prop.return_value._run_rpc.assert_called_once_with("system.get_display_info") + start.assert_not_called() + assert gui._gui_started_event.is_set() is startup_completed + + +def test_client_display_info_fails_without_running_server(): + gui = BECGuiClient() + with ( + mock.patch.object(gui, "_gui_is_alive", return_value=False), + mock.patch.object(gui, "_start") as start, + pytest.raises(RuntimeError, match="GUI is not alive"), + ): + gui.get_display_info() + start.assert_not_called() + + @pytest.mark.parametrize("theme", ["light", "dark"]) def test_client_utils_apply_theme_explicit(theme): gui = BECGuiClient() diff --git a/tests/unit_tests/test_display_info.py b/tests/unit_tests/test_display_info.py new file mode 100644 index 00000000..76235195 --- /dev/null +++ b/tests/unit_tests/test_display_info.py @@ -0,0 +1,50 @@ +"""Tests for diagnostics collected from the GUI process.""" + +import json +import os +import sys + +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.display_info import get_display_info + + +def test_display_info_uses_loaded_backend_not_environment(qapp, monkeypatch): + monkeypatch.setenv("QT_QPA_PLATFORM", "different-from-loaded-backend") + monkeypatch.setenv("XDG_CURRENT_DESKTOP", "GNOME") + monkeypatch.setenv("QT_PLUGIN_PATH", "/another/qt/plugins") + monkeypatch.setenv("UNRELATED_SECRET", "not-for-diagnostics") + + info = get_display_info() + + assert info["qt_platform"] == qapp.platformName() + assert info["pid"] == os.getpid() + assert info["python_executable"] == sys.executable + assert info["qt_library_paths"] == qapp.libraryPaths() + assert info["environment"]["QT_QPA_PLATFORM"] == "different-from-loaded-backend" + assert info["environment"]["XDG_CURRENT_DESKTOP"] == "GNOME" + assert info["environment"]["QT_PLUGIN_PATH"] == "/another/qt/plugins" + assert "UNRELATED_SECRET" not in info["environment"] + json.dumps(info) + + +def test_display_info_reports_visibility_without_changing_windows(qtbot, qapp): + window = QWidget() + qtbot.addWidget(window) + window.setObjectName("display_info_test") + window.show() + qapp.processEvents() + + for visible in (True, False): + window.setVisible(visible) + info = get_display_info() + state = next(item for item in info["windows"] if item["object_name"] == window.objectName()) + assert state["visible"] is visible + assert state["minimized"] is False + assert window.isVisible() is visible + + unshown = QWidget() + qtbot.addWidget(unshown) + assert unshown.windowHandle() is None + get_display_info() + assert unshown.windowHandle() is None diff --git a/tests/unit_tests/test_rpc_server.py b/tests/unit_tests/test_rpc_server.py index 27b947a9..fd6393dc 100644 --- a/tests/unit_tests/test_rpc_server.py +++ b/tests/unit_tests/test_rpc_server.py @@ -92,9 +92,15 @@ def test_rpc_server_system_capabilities_include_shutdown(rpc_server): assert rpc_server.run_system_rpc("system.list_capabilities", [], {}) == { "system.launch_dock_area": True, "system.shutdown": True, + "system.get_display_info": True, } +def test_display_info_is_available_through_system_rpc(rpc_server, qapp): + info = rpc_server.run_system_rpc("system.get_display_info", [], {}) + assert info["qt_platform"] == qapp.platformName() + + def test_rpc_server_system_shutdown_requests_gui_server_shutdown(rpc_server, qapp): gui_server = MagicMock() qapp.gui_server = gui_server