feat(gui): add display diagnostics

This commit is contained in:
wyzula_j committed 2026-09-10 16:11:56 +02:00
1 parent 351d98bfd8
commit 21cbb6d17c
7 files changed
+218 -1

No files matched your search

+15
View File
@@ -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.
+94
View File
@@ -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"])),
}
+8 -1
View File
@@ -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
+17
View File
@@ -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
+28
View File
@@ -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()
+50
View File
@@ -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
+6
View File
@@ -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