mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-05-09 16:22:08 +02:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7616102d8 | |||
| 5a497c3598 | |||
| 23e3644619 | |||
| a5db2dc340 | |||
| 2e8f43fcac | |||
| 09bb1121d8 | |||
| c9aaa77b3c | |||
| f7a1ee49a4 | |||
| 8e51c1adb6 | |||
| 846b6e6968 | |||
| f562c61e3c | |||
| bda5d38965 | |||
| 9b0ec9dd79 | |||
| 1754e759f0 | |||
| 308e84d0e1 | |||
| fa2ef83bb9 | |||
| 02cb393bb0 | |||
| 1d3e0214fd | |||
| 37747babda | |||
| 32f5d486d3 | |||
| 0ff1fdc815 | |||
| c7de320ca5 |
@@ -62,4 +62,4 @@ runs:
|
|||||||
uv pip install --system -e ./ophyd_devices
|
uv pip install --system -e ./ophyd_devices
|
||||||
uv pip install --system -e ./bec/bec_lib[dev]
|
uv pip install --system -e ./bec/bec_lib[dev]
|
||||||
uv pip install --system -e ./bec/bec_ipython_client
|
uv pip install --system -e ./bec/bec_ipython_client
|
||||||
uv pip install --system -e ./bec_widgets[dev,pyside6]
|
uv pip install --system -e ./bec_widgets[dev,qtermwidget]
|
||||||
|
|||||||
+3
-1
@@ -177,4 +177,6 @@ cython_debug/
|
|||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.idea/
|
||||||
|
#
|
||||||
|
tombi.toml
|
||||||
|
|||||||
@@ -1,6 +1,85 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
|
||||||
|
## v3.6.0 (2026-04-21)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Change resize mode to interactive
|
||||||
|
([`a5db2dc`](https://github.com/bec-project/bec_widgets/commit/a5db2dc340f3386e68b300fd4528a44f87cbbf97))
|
||||||
|
|
||||||
|
- Small usability changes
|
||||||
|
([`5a497c3`](https://github.com/bec-project/bec_widgets/commit/5a497c3598c2d8f27916d91d53c646d5d6d3a4a7))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add button/slot to pause/unpause logs
|
||||||
|
([`23e3644`](https://github.com/bec-project/bec_widgets/commit/23e3644619de958bcfdb8a0b2ee1f7c2ce05b235))
|
||||||
|
|
||||||
|
- Add logpanel to menu
|
||||||
|
([`2e8f43f`](https://github.com/bec-project/bec_widgets/commit/2e8f43fcac581cd1c227308198565d142a1bf276))
|
||||||
|
|
||||||
|
- Migrate logpanel to table model/view
|
||||||
|
([`09bb112`](https://github.com/bec-project/bec_widgets/commit/09bb1121d83bac1f6e4827daa476fbe7cd5b3a80))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.5.1 (2026-04-20)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Don't assume attr exists if we timed out waiting for it
|
||||||
|
([`f7a1ee4`](https://github.com/bec-project/bec_widgets/commit/f7a1ee49a42c58ba315c8957b45a80d862ffe745))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- Don't import real widgets in client
|
||||||
|
([`8e51c1a`](https://github.com/bec-project/bec_widgets/commit/8e51c1adb6a7658c54846794cf97b774cbac2193))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.5.0 (2026-04-14)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Connect signals the correct way around
|
||||||
|
([`f562c61`](https://github.com/bec-project/bec_widgets/commit/f562c61e3cec3387f6821bad74403beeb3436355))
|
||||||
|
|
||||||
|
- Create new bec shell if deleted
|
||||||
|
([`1754e75`](https://github.com/bec-project/bec_widgets/commit/1754e759f0c59f2f4063f661bacd334127326947))
|
||||||
|
|
||||||
|
- Formatting in plugin template
|
||||||
|
([`fa2ef83`](https://github.com/bec-project/bec_widgets/commit/fa2ef83bb9dfeeb4c5fc7cd77168c16101c32693))
|
||||||
|
|
||||||
|
- **bec_console**: Persistent bec session
|
||||||
|
([`9b0ec9d`](https://github.com/bec-project/bec_widgets/commit/9b0ec9dd79ad1adc5d211dd703db7441da965f34))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add qtermwidget plugin and replace web term
|
||||||
|
([`02cb393`](https://github.com/bec-project/bec_widgets/commit/02cb393bb086165dc64917b633d5570d02e1a2a9))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- Code cleanup
|
||||||
|
([`bda5d38`](https://github.com/bec-project/bec_widgets/commit/bda5d389651bb2b13734cd31159679e85b1bd583))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.4.4 (2026-04-14)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Check duplicate stream sub
|
||||||
|
([`c7de320`](https://github.com/bec-project/bec_widgets/commit/c7de320ca564264a31b84931f553170f25659685))
|
||||||
|
|
||||||
|
- Check for duplicate subscriptions in GUIClient
|
||||||
|
([`37747ba`](https://github.com/bec-project/bec_widgets/commit/37747babda407040333c6bd04646be9a49e0ee81))
|
||||||
|
|
||||||
|
- Make gui client registry callback non static
|
||||||
|
([`32f5d48`](https://github.com/bec-project/bec_widgets/commit/32f5d486d3fc8d41df2668c58932ae982819b285))
|
||||||
|
|
||||||
|
- Remove staticmethod subscription
|
||||||
|
([`0ff1fdc`](https://github.com/bec-project/bec_widgets/commit/0ff1fdc81578eec3ffc5d4030fca7b357a0b4c2f))
|
||||||
|
|
||||||
|
|
||||||
## v3.4.3 (2026-04-13)
|
## v3.4.3 (2026-04-13)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
|||||||
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
||||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||||
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
||||||
|
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole, BECShell
|
||||||
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
|
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
|
||||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||||
from bec_widgets.widgets.editors.web_console.web_console import BECShell, WebConsole
|
|
||||||
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
|
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
|
||||||
|
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ class DeveloperWidget(DockAreaWidget):
|
|||||||
|
|
||||||
self.console = BECShell(self, rpc_exposed=False)
|
self.console = BECShell(self, rpc_exposed=False)
|
||||||
self.console.setObjectName("BEC Shell")
|
self.console.setObjectName("BEC Shell")
|
||||||
self.terminal = WebConsole(self, rpc_exposed=False)
|
self.terminal = BecConsole(self, rpc_exposed=False)
|
||||||
self.terminal.setObjectName("Terminal")
|
self.terminal.setObjectName("Terminal")
|
||||||
self.monaco = MonacoDock(self, rpc_exposed=False, rpc_passthrough_children=False)
|
self.monaco = MonacoDock(self, rpc_exposed=False, rpc_passthrough_children=False)
|
||||||
self.monaco.setObjectName("MonacoEditor")
|
self.monaco.setObjectName("MonacoEditor")
|
||||||
@@ -410,23 +410,3 @@ class DeveloperWidget(DockAreaWidget):
|
|||||||
"""Clean up resources used by the developer widget."""
|
"""Clean up resources used by the developer widget."""
|
||||||
self.delete_all()
|
self.delete_all()
|
||||||
return super().cleanup()
|
return super().cleanup()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from bec_qthemes import apply_theme
|
|
||||||
from qtpy.QtWidgets import QApplication
|
|
||||||
|
|
||||||
from bec_widgets.applications.main_app import BECMainApp
|
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
|
||||||
apply_theme("dark")
|
|
||||||
|
|
||||||
_app = BECMainApp()
|
|
||||||
_app.show()
|
|
||||||
# developer_view.show()
|
|
||||||
# developer_view.setWindowTitle("Developer View")
|
|
||||||
# developer_view.resize(1920, 1080)
|
|
||||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
|
||||||
sys.exit(app.exec_())
|
|
||||||
|
|||||||
+151
-52
@@ -13,7 +13,7 @@ from typing import Literal, Optional
|
|||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
|
|
||||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
|
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
|
||||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module
|
from bec_widgets.utils.bec_plugin_helper import get_plugin_client_module
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ _Widgets = {
|
|||||||
"BECQueue": "BECQueue",
|
"BECQueue": "BECQueue",
|
||||||
"BECShell": "BECShell",
|
"BECShell": "BECShell",
|
||||||
"BECStatusBox": "BECStatusBox",
|
"BECStatusBox": "BECStatusBox",
|
||||||
|
"BecConsole": "BecConsole",
|
||||||
"DapComboBox": "DapComboBox",
|
"DapComboBox": "DapComboBox",
|
||||||
"DeviceBrowser": "DeviceBrowser",
|
"DeviceBrowser": "DeviceBrowser",
|
||||||
"Heatmap": "Heatmap",
|
"Heatmap": "Heatmap",
|
||||||
@@ -56,35 +57,24 @@ _Widgets = {
|
|||||||
"SignalLabel": "SignalLabel",
|
"SignalLabel": "SignalLabel",
|
||||||
"TextBox": "TextBox",
|
"TextBox": "TextBox",
|
||||||
"Waveform": "Waveform",
|
"Waveform": "Waveform",
|
||||||
"WebConsole": "WebConsole",
|
|
||||||
"WebsiteWidget": "WebsiteWidget",
|
"WebsiteWidget": "WebsiteWidget",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_plugin_widgets = get_all_plugin_widgets().as_dict()
|
|
||||||
plugin_client = get_plugin_client_module()
|
plugin_client = get_plugin_client_module()
|
||||||
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
|
|
||||||
|
|
||||||
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
|
|
||||||
for _widget in _overlap:
|
|
||||||
logger.warning(
|
|
||||||
f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !"
|
|
||||||
)
|
|
||||||
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
|
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
|
||||||
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
|
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
|
||||||
|
if plugin_name not in _Widgets:
|
||||||
|
_Widgets[plugin_name] = plugin_name
|
||||||
if plugin_name in globals():
|
if plugin_name in globals():
|
||||||
conflicting_file = (
|
|
||||||
inspect.getfile(_plugin_widgets[plugin_name])
|
|
||||||
if plugin_name in _plugin_widgets
|
|
||||||
else f"{plugin_client}"
|
|
||||||
)
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
|
f"Plugin widget {plugin_name} in {plugin_class._IMPORT_MODULE} conflicts with a built-in class!"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if plugin_name not in _overlap:
|
else:
|
||||||
globals()[plugin_name] = plugin_class
|
globals()[plugin_name] = plugin_class
|
||||||
|
Widgets = _WidgetsEnumType("Widgets", _Widgets)
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}")
|
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}")
|
||||||
|
|
||||||
@@ -92,6 +82,8 @@ except ImportError as e:
|
|||||||
class AdminView(RPCBase):
|
class AdminView(RPCBase):
|
||||||
"""A view for administrators to change the current active experiment, manage messaging"""
|
"""A view for administrators to change the current active experiment, manage messaging"""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.applications.views.admin_view.admin_view"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def activate(self) -> "None":
|
def activate(self) -> "None":
|
||||||
"""
|
"""
|
||||||
@@ -100,6 +92,8 @@ class AdminView(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class AutoUpdates(RPCBase):
|
class AutoUpdates(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.containers.auto_update.auto_updates"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def enabled(self) -> "bool":
|
def enabled(self) -> "bool":
|
||||||
@@ -136,6 +130,8 @@ class AutoUpdates(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class AvailableDeviceResources(RPCBase):
|
class AvailableDeviceResources(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -156,6 +152,8 @@ class AvailableDeviceResources(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class BECDockArea(RPCBase):
|
class BECDockArea(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.containers.dock_area.dock_area"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def new(
|
def new(
|
||||||
self,
|
self,
|
||||||
@@ -391,6 +389,8 @@ class BECDockArea(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class BECMainWindow(RPCBase):
|
class BECMainWindow(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.containers.main_window.main_window"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -413,6 +413,8 @@ class BECMainWindow(RPCBase):
|
|||||||
class BECProgressBar(RPCBase):
|
class BECProgressBar(RPCBase):
|
||||||
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template."""
|
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.progress.bec_progressbar.bec_progressbar"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_value(self, value):
|
def set_value(self, value):
|
||||||
"""
|
"""
|
||||||
@@ -486,6 +488,8 @@ class BECProgressBar(RPCBase):
|
|||||||
class BECQueue(RPCBase):
|
class BECQueue(RPCBase):
|
||||||
"""Widget to display the BEC queue."""
|
"""Widget to display the BEC queue."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.services.bec_queue.bec_queue"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -506,7 +510,9 @@ class BECQueue(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class BECShell(RPCBase):
|
class BECShell(RPCBase):
|
||||||
"""A WebConsole pre-configured to run the BEC shell."""
|
"""A BecConsole pre-configured to run the BEC shell."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.editors.bec_console.bec_console"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
@@ -530,6 +536,8 @@ class BECShell(RPCBase):
|
|||||||
class BECStatusBox(RPCBase):
|
class BECStatusBox(RPCBase):
|
||||||
"""An autonomous widget to display the status of BEC services."""
|
"""An autonomous widget to display the status of BEC services."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.services.bec_status_box.bec_status_box"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def get_server_state(self) -> "str":
|
def get_server_state(self) -> "str":
|
||||||
"""
|
"""
|
||||||
@@ -565,6 +573,8 @@ class BECStatusBox(RPCBase):
|
|||||||
class BaseROI(RPCBase):
|
class BaseROI(RPCBase):
|
||||||
"""Base class for all Region of Interest (ROI) implementations."""
|
"""Base class for all Region of Interest (ROI) implementations."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def label(self) -> "str":
|
def label(self) -> "str":
|
||||||
@@ -691,9 +701,35 @@ class BaseROI(RPCBase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BecConsole(RPCBase):
|
||||||
|
"""A console widget with access to a shared registry of terminals, such that instances can be moved around."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.editors.bec_console.bec_console"
|
||||||
|
|
||||||
|
@rpc_call
|
||||||
|
def remove(self):
|
||||||
|
"""
|
||||||
|
Cleanup the BECConnector
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rpc_call
|
||||||
|
def attach(self):
|
||||||
|
"""
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rpc_call
|
||||||
|
def detach(self):
|
||||||
|
"""
|
||||||
|
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class CircularROI(RPCBase):
|
class CircularROI(RPCBase):
|
||||||
"""Circular Region of Interest with center/diameter tracking and auto-labeling."""
|
"""Circular Region of Interest with center/diameter tracking and auto-labeling."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def label(self) -> "str":
|
def label(self) -> "str":
|
||||||
@@ -821,6 +857,8 @@ class CircularROI(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class Curve(RPCBase):
|
class Curve(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.curve"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -987,6 +1025,8 @@ class Curve(RPCBase):
|
|||||||
class DapComboBox(RPCBase):
|
class DapComboBox(RPCBase):
|
||||||
"""Editable combobox listing the available DAP models."""
|
"""Editable combobox listing the available DAP models."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.dap.dap_combo_box.dap_combo_box"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def select_y_axis(self, y_axis: str):
|
def select_y_axis(self, y_axis: str):
|
||||||
"""
|
"""
|
||||||
@@ -1018,6 +1058,8 @@ class DapComboBox(RPCBase):
|
|||||||
class DeveloperView(RPCBase):
|
class DeveloperView(RPCBase):
|
||||||
"""A view for users to write scripts and macros and execute them within the application."""
|
"""A view for users to write scripts and macros and execute them within the application."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.applications.views.developer_view.developer_view"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def activate(self) -> "None":
|
def activate(self) -> "None":
|
||||||
"""
|
"""
|
||||||
@@ -1028,6 +1070,8 @@ class DeveloperView(RPCBase):
|
|||||||
class DeviceBrowser(RPCBase):
|
class DeviceBrowser(RPCBase):
|
||||||
"""DeviceBrowser is a widget that displays all available devices in the current BEC session."""
|
"""DeviceBrowser is a widget that displays all available devices in the current BEC session."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.services.device_browser.device_browser"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -1050,6 +1094,8 @@ class DeviceBrowser(RPCBase):
|
|||||||
class DeviceInitializationProgressBar(RPCBase):
|
class DeviceInitializationProgressBar(RPCBase):
|
||||||
"""A progress bar that displays the progress of device initialization."""
|
"""A progress bar that displays the progress of device initialization."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -1072,6 +1118,8 @@ class DeviceInitializationProgressBar(RPCBase):
|
|||||||
class DeviceInputBase(RPCBase):
|
class DeviceInputBase(RPCBase):
|
||||||
"""Mixin base class for device input widgets."""
|
"""Mixin base class for device input widgets."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.control.device_input.base_classes.device_input_base"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -1094,6 +1142,8 @@ class DeviceInputBase(RPCBase):
|
|||||||
class DeviceManagerView(RPCBase):
|
class DeviceManagerView(RPCBase):
|
||||||
"""A view for users to manage devices within the application."""
|
"""A view for users to manage devices within the application."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.applications.views.device_manager_view.device_manager_view"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def activate(self) -> "None":
|
def activate(self) -> "None":
|
||||||
"""
|
"""
|
||||||
@@ -1104,6 +1154,8 @@ class DeviceManagerView(RPCBase):
|
|||||||
class DockAreaView(RPCBase):
|
class DockAreaView(RPCBase):
|
||||||
"""Modular dock area view for arranging and managing multiple dockable widgets."""
|
"""Modular dock area view for arranging and managing multiple dockable widgets."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.applications.views.dock_area_view.dock_area_view"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def activate(self) -> "None":
|
def activate(self) -> "None":
|
||||||
"""
|
"""
|
||||||
@@ -1347,6 +1399,8 @@ class DockAreaView(RPCBase):
|
|||||||
class DockAreaWidget(RPCBase):
|
class DockAreaWidget(RPCBase):
|
||||||
"""Lightweight dock area that exposes the core Qt ADS docking helpers without any"""
|
"""Lightweight dock area that exposes the core Qt ADS docking helpers without any"""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.containers.dock_area.basic_dock_area"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def new(
|
def new(
|
||||||
self,
|
self,
|
||||||
@@ -1531,6 +1585,8 @@ class DockAreaWidget(RPCBase):
|
|||||||
class EllipticalROI(RPCBase):
|
class EllipticalROI(RPCBase):
|
||||||
"""Elliptical Region of Interest with centre/width/height tracking and auto-labelling."""
|
"""Elliptical Region of Interest with centre/width/height tracking and auto-labelling."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def label(self) -> "str":
|
def label(self) -> "str":
|
||||||
@@ -1653,6 +1709,8 @@ class EllipticalROI(RPCBase):
|
|||||||
class Heatmap(RPCBase):
|
class Heatmap(RPCBase):
|
||||||
"""Heatmap widget for visualizing 2d grid data with color mapping for the z-axis."""
|
"""Heatmap widget for visualizing 2d grid data with color mapping for the z-axis."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.heatmap.heatmap"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -2351,6 +2409,8 @@ class Heatmap(RPCBase):
|
|||||||
class Image(RPCBase):
|
class Image(RPCBase):
|
||||||
"""Image widget for displaying 2D data."""
|
"""Image widget for displaying 2D data."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.image.image"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -2962,6 +3022,8 @@ class Image(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class ImageItem(RPCBase):
|
class ImageItem(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.image.image_item"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def color_map(self) -> "str":
|
def color_map(self) -> "str":
|
||||||
@@ -3112,6 +3174,8 @@ class ImageItem(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class LaunchWindow(RPCBase):
|
class LaunchWindow(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.applications.launch_window"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def show_launcher(self):
|
def show_launcher(self):
|
||||||
"""
|
"""
|
||||||
@@ -3126,33 +3190,38 @@ class LaunchWindow(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class LogPanel(RPCBase):
|
class LogPanel(RPCBase):
|
||||||
"""Displays a log panel"""
|
"""Live display of the BEC logs in a table view."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.utility.logpanel.logpanel"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_plain_text(self, text: str) -> None:
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
Set the plain text of the widget.
|
Cleanup the BECConnector
|
||||||
|
|
||||||
Args:
|
|
||||||
text (str): The text to set.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_html_text(self, text: str) -> None:
|
def attach(self):
|
||||||
|
"""
|
||||||
|
None
|
||||||
"""
|
"""
|
||||||
Set the HTML text of the widget.
|
|
||||||
|
|
||||||
Args:
|
@rpc_call
|
||||||
text (str): The text to set.
|
def detach(self):
|
||||||
|
"""
|
||||||
|
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class Minesweeper(RPCBase): ...
|
class Minesweeper(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.games.minesweeper"
|
||||||
|
|
||||||
|
|
||||||
class MonacoDock(RPCBase):
|
class MonacoDock(RPCBase):
|
||||||
"""MonacoDock is a dock widget that contains Monaco editor instances."""
|
"""MonacoDock is a dock widget that contains Monaco editor instances."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.editors.monaco.monaco_dock"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def new(
|
def new(
|
||||||
self,
|
self,
|
||||||
@@ -3337,6 +3406,8 @@ class MonacoDock(RPCBase):
|
|||||||
class MonacoWidget(RPCBase):
|
class MonacoWidget(RPCBase):
|
||||||
"""A simple Monaco editor widget"""
|
"""A simple Monaco editor widget"""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.editors.monaco.monaco_widget"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_text(
|
def set_text(
|
||||||
self, text: "str", file_name: "str | None" = None, reset: "bool" = False
|
self, text: "str", file_name: "str | None" = None, reset: "bool" = False
|
||||||
@@ -3511,6 +3582,8 @@ class MonacoWidget(RPCBase):
|
|||||||
class MotorMap(RPCBase):
|
class MotorMap(RPCBase):
|
||||||
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
|
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.motor_map.motor_map"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -3981,6 +4054,8 @@ class MotorMap(RPCBase):
|
|||||||
class MultiWaveform(RPCBase):
|
class MultiWaveform(RPCBase):
|
||||||
"""MultiWaveform widget for displaying multiple waveforms emitted by a single signal."""
|
"""MultiWaveform widget for displaying multiple waveforms emitted by a single signal."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.multi_waveform.multi_waveform"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -4440,6 +4515,8 @@ class MultiWaveform(RPCBase):
|
|||||||
class PdfViewerWidget(RPCBase):
|
class PdfViewerWidget(RPCBase):
|
||||||
"""A widget to display PDF documents with toolbar controls."""
|
"""A widget to display PDF documents with toolbar controls."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.utility.pdf_viewer.pdf_viewer"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def load_pdf(self, file_path: str):
|
def load_pdf(self, file_path: str):
|
||||||
"""
|
"""
|
||||||
@@ -4571,6 +4648,10 @@ class PdfViewerWidget(RPCBase):
|
|||||||
class PositionIndicator(RPCBase):
|
class PositionIndicator(RPCBase):
|
||||||
"""Display a position within a defined range, e.g. motor limits."""
|
"""Display a position within a defined range, e.g. motor limits."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = (
|
||||||
|
"bec_widgets.widgets.control.device_control.position_indicator.position_indicator"
|
||||||
|
)
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_value(self, position: float):
|
def set_value(self, position: float):
|
||||||
"""
|
"""
|
||||||
@@ -4636,6 +4717,10 @@ class PositionIndicator(RPCBase):
|
|||||||
class PositionerBox(RPCBase):
|
class PositionerBox(RPCBase):
|
||||||
"""Simple Widget to control a positioner in box form"""
|
"""Simple Widget to control a positioner in box form"""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = (
|
||||||
|
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box"
|
||||||
|
)
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_positioner(self, positioner: "str | Positioner"):
|
def set_positioner(self, positioner: "str | Positioner"):
|
||||||
"""
|
"""
|
||||||
@@ -4668,6 +4753,8 @@ class PositionerBox(RPCBase):
|
|||||||
class PositionerBox2D(RPCBase):
|
class PositionerBox2D(RPCBase):
|
||||||
"""Simple Widget to control two positioners in box form"""
|
"""Simple Widget to control two positioners in box form"""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_positioner_hor(self, positioner: "str | Positioner"):
|
def set_positioner_hor(self, positioner: "str | Positioner"):
|
||||||
"""
|
"""
|
||||||
@@ -4737,6 +4824,8 @@ class PositionerBox2D(RPCBase):
|
|||||||
class PositionerControlLine(RPCBase):
|
class PositionerControlLine(RPCBase):
|
||||||
"""A widget that controls a single device."""
|
"""A widget that controls a single device."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_positioner(self, positioner: "str | Positioner"):
|
def set_positioner(self, positioner: "str | Positioner"):
|
||||||
"""
|
"""
|
||||||
@@ -4769,6 +4858,8 @@ class PositionerControlLine(RPCBase):
|
|||||||
class PositionerGroup(RPCBase):
|
class PositionerGroup(RPCBase):
|
||||||
"""Simple Widget to control a positioner in box form"""
|
"""Simple Widget to control a positioner in box form"""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_group.positioner_group"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_positioners(self, device_names: "str"):
|
def set_positioners(self, device_names: "str"):
|
||||||
"""
|
"""
|
||||||
@@ -4800,6 +4891,8 @@ class PositionerGroup(RPCBase):
|
|||||||
class RectangularROI(RPCBase):
|
class RectangularROI(RPCBase):
|
||||||
"""Defines a rectangular Region of Interest (ROI) with additional functionality."""
|
"""Defines a rectangular Region of Interest (ROI) with additional functionality."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def label(self) -> "str":
|
def label(self) -> "str":
|
||||||
@@ -4929,6 +5022,8 @@ class RectangularROI(RPCBase):
|
|||||||
class ResumeButton(RPCBase):
|
class ResumeButton(RPCBase):
|
||||||
"""A button that continue scan queue."""
|
"""A button that continue scan queue."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.control.buttons.button_resume.button_resume"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -4949,6 +5044,8 @@ class ResumeButton(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class Ring(RPCBase):
|
class Ring(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.progress.ring_progress_bar.ring"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_value(self, value: "int | float"):
|
def set_value(self, value: "int | float"):
|
||||||
"""
|
"""
|
||||||
@@ -5042,6 +5139,8 @@ class Ring(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class RingProgressBar(RPCBase):
|
class RingProgressBar(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -5121,12 +5220,14 @@ class RingProgressBar(RPCBase):
|
|||||||
class SBBMonitor(RPCBase):
|
class SBBMonitor(RPCBase):
|
||||||
"""A widget to display the SBB monitor website."""
|
"""A widget to display the SBB monitor website."""
|
||||||
|
|
||||||
...
|
_IMPORT_MODULE = "bec_widgets.widgets.editors.sbb_monitor.sbb_monitor"
|
||||||
|
|
||||||
|
|
||||||
class ScanControl(RPCBase):
|
class ScanControl(RPCBase):
|
||||||
"""Widget to submit new scans to the queue."""
|
"""Widget to submit new scans to the queue."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.control.scan_control.scan_control"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def attach(self):
|
def attach(self):
|
||||||
"""
|
"""
|
||||||
@@ -5150,6 +5251,8 @@ class ScanControl(RPCBase):
|
|||||||
class ScanProgressBar(RPCBase):
|
class ScanProgressBar(RPCBase):
|
||||||
"""Widget to display a progress bar that is hooked up to the scan progress of a scan."""
|
"""Widget to display a progress bar that is hooked up to the scan progress of a scan."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.progress.scan_progressbar.scan_progressbar"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -5172,6 +5275,8 @@ class ScanProgressBar(RPCBase):
|
|||||||
class ScatterCurve(RPCBase):
|
class ScatterCurve(RPCBase):
|
||||||
"""Scatter curve item for the scatter waveform widget."""
|
"""Scatter curve item for the scatter waveform widget."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.scatter_waveform.scatter_curve"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def color_map(self) -> "str":
|
def color_map(self) -> "str":
|
||||||
@@ -5181,6 +5286,8 @@ class ScatterCurve(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class ScatterWaveform(RPCBase):
|
class ScatterWaveform(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.scatter_waveform.scatter_waveform"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -5648,6 +5755,8 @@ class ScatterWaveform(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class SignalLabel(RPCBase):
|
class SignalLabel(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.utility.signal_label.signal_label"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def custom_label(self) -> "str":
|
def custom_label(self) -> "str":
|
||||||
@@ -5792,6 +5901,8 @@ class SignalLabel(RPCBase):
|
|||||||
class TextBox(RPCBase):
|
class TextBox(RPCBase):
|
||||||
"""A widget that displays text in plain and HTML format"""
|
"""A widget that displays text in plain and HTML format"""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.editors.text_box.text_box"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_plain_text(self, text: str) -> None:
|
def set_plain_text(self, text: str) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -5814,6 +5925,8 @@ class TextBox(RPCBase):
|
|||||||
class ViewBase(RPCBase):
|
class ViewBase(RPCBase):
|
||||||
"""Wrapper for a content widget used inside the main app's stacked view."""
|
"""Wrapper for a content widget used inside the main app's stacked view."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.applications.views.view"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def activate(self) -> "None":
|
def activate(self) -> "None":
|
||||||
"""
|
"""
|
||||||
@@ -5824,6 +5937,8 @@ class ViewBase(RPCBase):
|
|||||||
class Waveform(RPCBase):
|
class Waveform(RPCBase):
|
||||||
"""Widget for plotting waveforms."""
|
"""Widget for plotting waveforms."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.waveform"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -6402,6 +6517,8 @@ class Waveform(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class WaveformViewInline(RPCBase):
|
class WaveformViewInline(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.applications.views.view"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def activate(self) -> "None":
|
def activate(self) -> "None":
|
||||||
"""
|
"""
|
||||||
@@ -6410,6 +6527,8 @@ class WaveformViewInline(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class WaveformViewPopup(RPCBase):
|
class WaveformViewPopup(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.applications.views.view"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def activate(self) -> "None":
|
def activate(self) -> "None":
|
||||||
"""
|
"""
|
||||||
@@ -6417,31 +6536,11 @@ class WaveformViewPopup(RPCBase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class WebConsole(RPCBase):
|
|
||||||
"""A simple widget to display a website"""
|
|
||||||
|
|
||||||
@rpc_call
|
|
||||||
def remove(self):
|
|
||||||
"""
|
|
||||||
Cleanup the BECConnector
|
|
||||||
"""
|
|
||||||
|
|
||||||
@rpc_call
|
|
||||||
def attach(self):
|
|
||||||
"""
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
|
|
||||||
@rpc_call
|
|
||||||
def detach(self):
|
|
||||||
"""
|
|
||||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class WebsiteWidget(RPCBase):
|
class WebsiteWidget(RPCBase):
|
||||||
"""A simple widget to display a website"""
|
"""A simple widget to display a website"""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.editors.website.website"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_url(self, url: str) -> None:
|
def set_url(self, url: str) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from typing import TYPE_CHECKING, Literal, TypeAlias, cast
|
from typing import TYPE_CHECKING, Callable, Literal, TypeAlias, cast
|
||||||
|
|
||||||
from bec_lib.endpoints import MessageEndpoints
|
from bec_lib.endpoints import EndpointInfo, MessageEndpoints
|
||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
@@ -232,6 +232,11 @@ class BECGuiClient(RPCBase):
|
|||||||
"""The launcher object."""
|
"""The launcher object."""
|
||||||
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
|
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
|
||||||
|
|
||||||
|
def _safe_register_stream(self, endpoint: EndpointInfo, cb: Callable, **kwargs):
|
||||||
|
"""Check if already registered for registration in idempotent functions."""
|
||||||
|
if not self._client.connector.any_stream_is_registered(endpoint, cb=cb):
|
||||||
|
self._client.connector.register(endpoint, cb=cb, **kwargs)
|
||||||
|
|
||||||
def connect_to_gui_server(self, gui_id: str) -> None:
|
def connect_to_gui_server(self, gui_id: str) -> None:
|
||||||
"""Connect to a GUI server"""
|
"""Connect to a GUI server"""
|
||||||
# Unregister the old callback
|
# Unregister the old callback
|
||||||
@@ -247,10 +252,9 @@ class BECGuiClient(RPCBase):
|
|||||||
self._ipython_registry = {}
|
self._ipython_registry = {}
|
||||||
|
|
||||||
# Register the new callback
|
# Register the new callback
|
||||||
self._client.connector.register(
|
self._safe_register_stream(
|
||||||
MessageEndpoints.gui_registry_state(self._gui_id),
|
MessageEndpoints.gui_registry_state(self._gui_id),
|
||||||
cb=self._handle_registry_update,
|
cb=self._handle_registry_update,
|
||||||
parent=self,
|
|
||||||
from_start=True,
|
from_start=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -531,20 +535,14 @@ class BECGuiClient(RPCBase):
|
|||||||
|
|
||||||
def _start(self, wait: bool = False) -> None:
|
def _start(self, wait: bool = False) -> None:
|
||||||
self._killed = False
|
self._killed = False
|
||||||
self._client.connector.register(
|
self._safe_register_stream(
|
||||||
MessageEndpoints.gui_registry_state(self._gui_id),
|
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
|
||||||
cb=self._handle_registry_update,
|
|
||||||
parent=self,
|
|
||||||
)
|
)
|
||||||
return self._start_server(wait=wait)
|
return self._start_server(wait=wait)
|
||||||
|
|
||||||
@staticmethod
|
def _handle_registry_update(self, msg: dict[str, GUIRegistryStateMessage]) -> None:
|
||||||
def _handle_registry_update(
|
|
||||||
msg: dict[str, GUIRegistryStateMessage], parent: BECGuiClient
|
|
||||||
) -> None:
|
|
||||||
# This was causing a deadlock during shutdown, not sure why.
|
# This was causing a deadlock during shutdown, not sure why.
|
||||||
# with self._lock:
|
# with self._lock:
|
||||||
self = parent
|
|
||||||
self._server_registry = cast(dict[str, RegistryState], msg["data"].state)
|
self._server_registry = cast(dict[str, RegistryState], msg["data"].state)
|
||||||
self._update_dynamic_namespace(self._server_registry)
|
self._update_dynamic_namespace(self._server_registry)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import inspect
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import get_overloads
|
||||||
|
|
||||||
import black
|
import black
|
||||||
import isort
|
import isort
|
||||||
@@ -18,20 +19,6 @@ from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
|
|||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
if sys.version_info >= (3, 11):
|
|
||||||
from typing import get_overloads
|
|
||||||
else:
|
|
||||||
print(
|
|
||||||
"Python version is less than 3.11, using dummy function for get_overloads. "
|
|
||||||
"If you want to use the real function 'typing.get_overloads()', please use Python 3.11 or later."
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_overloads(_obj):
|
|
||||||
"""
|
|
||||||
Dummy function for Python versions before 3.11.
|
|
||||||
"""
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
class ClientGenerator:
|
class ClientGenerator:
|
||||||
def __init__(self, base=False):
|
def __init__(self, base=False):
|
||||||
@@ -54,7 +41,7 @@ from __future__ import annotations
|
|||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
|
|
||||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
|
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
|
||||||
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
|
{"from bec_widgets.utils.bec_plugin_helper import get_plugin_client_module" if self._base else ""}
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
@@ -111,27 +98,19 @@ _Widgets = {
|
|||||||
self.content += """
|
self.content += """
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_plugin_widgets = get_all_plugin_widgets().as_dict()
|
|
||||||
plugin_client = get_plugin_client_module()
|
plugin_client = get_plugin_client_module()
|
||||||
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
|
|
||||||
|
|
||||||
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
|
|
||||||
for _widget in _overlap:
|
|
||||||
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
|
|
||||||
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
|
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
|
||||||
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
|
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
|
||||||
|
if plugin_name not in _Widgets:
|
||||||
|
_Widgets[plugin_name] = plugin_name
|
||||||
if plugin_name in globals():
|
if plugin_name in globals():
|
||||||
conflicting_file = (
|
|
||||||
inspect.getfile(_plugin_widgets[plugin_name])
|
|
||||||
if plugin_name in _plugin_widgets
|
|
||||||
else f"{plugin_client}"
|
|
||||||
)
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
|
f"Plugin widget {plugin_name} in {plugin_class._IMPORT_MODULE} conflicts with a built-in class!"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if plugin_name not in _overlap:
|
else:
|
||||||
globals()[plugin_name] = plugin_class
|
globals()[plugin_name] = plugin_class
|
||||||
|
Widgets = _WidgetsEnumType("Widgets", _Widgets)
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
|
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
|
||||||
"""
|
"""
|
||||||
@@ -146,12 +125,8 @@ except ImportError as e:
|
|||||||
|
|
||||||
class_name = cls.__name__
|
class_name = cls.__name__
|
||||||
|
|
||||||
if class_name == "BECDockArea":
|
self.content += f"""
|
||||||
self.content += f"""
|
class {class_name}(RPCBase):\n"""
|
||||||
class {class_name}(RPCBase):"""
|
|
||||||
else:
|
|
||||||
self.content += f"""
|
|
||||||
class {class_name}(RPCBase):"""
|
|
||||||
|
|
||||||
if cls.__doc__:
|
if cls.__doc__:
|
||||||
# We only want the first line of the docstring
|
# We only want the first line of the docstring
|
||||||
@@ -162,13 +137,9 @@ class {class_name}(RPCBase):"""
|
|||||||
else:
|
else:
|
||||||
class_docs = cls.__doc__.split("\n")[1]
|
class_docs = cls.__doc__.split("\n")[1]
|
||||||
self.content += f"""
|
self.content += f"""
|
||||||
\"\"\"{class_docs}\"\"\"
|
\"\"\"{class_docs}\"\"\"\n"""
|
||||||
"""
|
|
||||||
user_access_entries = self._get_user_access_entries(cls)
|
user_access_entries = self._get_user_access_entries(cls)
|
||||||
if not user_access_entries:
|
self.content += f' _IMPORT_MODULE="{cls.__module__}"\n'
|
||||||
self.content += """...
|
|
||||||
"""
|
|
||||||
|
|
||||||
for method_entry in user_access_entries:
|
for method_entry in user_access_entries:
|
||||||
method, obj, is_property_setter = self._resolve_method_object(cls, method_entry)
|
method, obj, is_property_setter = self._resolve_method_object(cls, method_entry)
|
||||||
if obj is None:
|
if obj is None:
|
||||||
|
|||||||
@@ -248,9 +248,7 @@ class RPCBase:
|
|||||||
self._rpc_response = None
|
self._rpc_response = None
|
||||||
self._msg_wait_event.clear()
|
self._msg_wait_event.clear()
|
||||||
self._client.connector.register(
|
self._client.connector.register(
|
||||||
MessageEndpoints.gui_instruction_response(request_id),
|
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
|
||||||
cb=self._on_rpc_response,
|
|
||||||
parent=self,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||||
@@ -276,11 +274,10 @@ class RPCBase:
|
|||||||
self._rpc_response = None
|
self._rpc_response = None
|
||||||
return self._create_widget_from_msg_result(msg_result)
|
return self._create_widget_from_msg_result(msg_result)
|
||||||
|
|
||||||
@staticmethod
|
def _on_rpc_response(self, msg_obj: MessageObject) -> None:
|
||||||
def _on_rpc_response(msg_obj: MessageObject, parent: RPCBase) -> None:
|
|
||||||
msg = cast(messages.RequestResponseMessage, msg_obj.value)
|
msg = cast(messages.RequestResponseMessage, msg_obj.value)
|
||||||
parent._rpc_response = msg
|
self._rpc_response = msg
|
||||||
parent._msg_wait_event.set()
|
self._msg_wait_event.set()
|
||||||
|
|
||||||
def _create_widget_from_msg_result(self, msg_result):
|
def _create_widget_from_msg_result(self, msg_result):
|
||||||
if msg_result is None:
|
if msg_result is None:
|
||||||
|
|||||||
@@ -175,12 +175,15 @@ class BECDispatcher:
|
|||||||
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
|
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
|
||||||
"""
|
"""
|
||||||
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
|
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
|
||||||
if qt_slot not in self._registered_slots:
|
if not self.client.connector.any_stream_is_registered(topics, qt_slot):
|
||||||
self._registered_slots[qt_slot] = qt_slot
|
if qt_slot not in self._registered_slots:
|
||||||
qt_slot = self._registered_slots[qt_slot]
|
self._registered_slots[qt_slot] = qt_slot
|
||||||
self.client.connector.register(topics, cb=qt_slot, **kwargs)
|
qt_slot = self._registered_slots[qt_slot]
|
||||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
self.client.connector.register(topics, cb=qt_slot, **kwargs)
|
||||||
qt_slot.topics.update(set(topics_str))
|
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||||
|
qt_slot.topics.update(set(topics_str))
|
||||||
|
else:
|
||||||
|
logger.warning(f"Attempted to create duplicate stream subscription for {topics=}")
|
||||||
|
|
||||||
def disconnect_slot(
|
def disconnect_slot(
|
||||||
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]
|
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no
|
|||||||
|
|
||||||
def createWidget(self, parent):
|
def createWidget(self, parent):
|
||||||
if parent is None:
|
if parent is None:
|
||||||
return QWidget()
|
return QWidget()
|
||||||
t = {plugin_name_pascal}(parent)
|
t = {plugin_name_pascal}(parent)
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
|||||||
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
||||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D
|
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D
|
||||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||||
from bec_widgets.widgets.editors.web_console.web_console import BECShell, WebConsole
|
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole, BECShell
|
||||||
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
|
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
|
||||||
from bec_widgets.widgets.plots.image.image import Image
|
from bec_widgets.widgets.plots.image.image import Image
|
||||||
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
|
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
|
||||||
@@ -79,7 +79,7 @@ from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
|||||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
|
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
|
||||||
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
|
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
|
||||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
|
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
|
||||||
from bec_widgets.widgets.utility.logpanel import LogPanel
|
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
|
||||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
@@ -372,10 +372,11 @@ class BECDockArea(DockAreaWidget):
|
|||||||
"Add Circular ProgressBar",
|
"Add Circular ProgressBar",
|
||||||
"RingProgressBar",
|
"RingProgressBar",
|
||||||
),
|
),
|
||||||
"terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"),
|
"terminal": (BecConsole.ICON_NAME, "Add Terminal", "BecConsole"),
|
||||||
"bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"),
|
"bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"),
|
||||||
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
|
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
|
||||||
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
|
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
|
||||||
|
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel", "LogPanel"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create expandable menu actions (original behavior)
|
# Create expandable menu actions (original behavior)
|
||||||
@@ -487,9 +488,7 @@ class BECDockArea(DockAreaWidget):
|
|||||||
# first two items not needed for this part
|
# first two items not needed for this part
|
||||||
for key, (_, _, widget_type) in mapping.items():
|
for key, (_, _, widget_type) in mapping.items():
|
||||||
act = menu.actions[key].action
|
act = menu.actions[key].action
|
||||||
if widget_type == "LogPanel":
|
if key == "terminal":
|
||||||
act.setEnabled(False) # keep disabled per issue #644
|
|
||||||
elif key == "terminal":
|
|
||||||
act.triggered.connect(
|
act.triggered.connect(
|
||||||
lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None)
|
lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None)
|
||||||
)
|
)
|
||||||
@@ -510,10 +509,7 @@ class BECDockArea(DockAreaWidget):
|
|||||||
for action_id, (_, _, widget_type) in mapping.items():
|
for action_id, (_, _, widget_type) in mapping.items():
|
||||||
flat_action_id = f"flat_{action_id}"
|
flat_action_id = f"flat_{action_id}"
|
||||||
flat_action = self.toolbar.components.get_action(flat_action_id).action
|
flat_action = self.toolbar.components.get_action(flat_action_id).action
|
||||||
if widget_type == "LogPanel":
|
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
|
||||||
flat_action.setEnabled(False) # keep disabled per issue #644
|
|
||||||
else:
|
|
||||||
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
|
|
||||||
|
|
||||||
_connect_flat_actions(self._ACTION_MAPPINGS["menu_plots"])
|
_connect_flat_actions(self._ACTION_MAPPINGS["menu_plots"])
|
||||||
_connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"])
|
_connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"])
|
||||||
|
|||||||
@@ -0,0 +1,605 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from uuid import uuid4
|
||||||
|
from weakref import WeakValueDictionary
|
||||||
|
|
||||||
|
import shiboken6
|
||||||
|
from bec_lib.logger import bec_logger
|
||||||
|
from qtpy.QtCore import Qt, Signal
|
||||||
|
from qtpy.QtGui import QMouseEvent
|
||||||
|
from qtpy.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QStackedLayout,
|
||||||
|
QTabWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
|
from bec_widgets.widgets.utility.bec_term.protocol import BecTerminal
|
||||||
|
from bec_widgets.widgets.utility.bec_term.util import get_current_bec_term_class
|
||||||
|
|
||||||
|
logger = bec_logger.logger
|
||||||
|
|
||||||
|
_BecTermClass = get_current_bec_term_class()
|
||||||
|
|
||||||
|
# Note on definitions:
|
||||||
|
# Terminal: an instance of a terminal widget with a system shell
|
||||||
|
# Console: one of possibly several widgets which may share ownership of one single terminal
|
||||||
|
# Shell: a Console set to start the BEC IPython client in its terminal
|
||||||
|
|
||||||
|
|
||||||
|
class ConsoleMode(str, enum.Enum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
INACTIVE = "inactive"
|
||||||
|
HIDDEN = "hidden"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _TerminalOwnerInfo:
|
||||||
|
"""Should be managed only by the BecConsoleRegistry. Consoles should ask the registry for
|
||||||
|
necessary ownership info."""
|
||||||
|
|
||||||
|
owner_console_id: str | None = None
|
||||||
|
registered_console_ids: set[str] = field(default_factory=set)
|
||||||
|
instance: BecTerminal | None = None
|
||||||
|
terminal_id: str = ""
|
||||||
|
initialized: bool = False
|
||||||
|
persist_session: bool = False
|
||||||
|
fallback_holder: QWidget | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BecConsoleRegistry:
|
||||||
|
"""
|
||||||
|
A registry for the BecConsole class to manage its instances.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""
|
||||||
|
Initialize the registry.
|
||||||
|
"""
|
||||||
|
self._consoles: WeakValueDictionary[str, BecConsole] = WeakValueDictionary()
|
||||||
|
self._terminal_registry: dict[str, _TerminalOwnerInfo] = {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_valid_qobject(obj: object | None) -> bool:
|
||||||
|
return obj is not None and shiboken6.isValid(obj)
|
||||||
|
|
||||||
|
def _connect_app_cleanup(self) -> None:
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
return
|
||||||
|
app.aboutToQuit.connect(self.clear, Qt.ConnectionType.UniqueConnection)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _new_terminal_info(console: BecConsole) -> _TerminalOwnerInfo:
|
||||||
|
term = _BecTermClass()
|
||||||
|
return _TerminalOwnerInfo(
|
||||||
|
registered_console_ids={console.console_id},
|
||||||
|
owner_console_id=console.console_id,
|
||||||
|
instance=term,
|
||||||
|
terminal_id=console.terminal_id,
|
||||||
|
persist_session=console.persist_terminal_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _replace_terminal(info: _TerminalOwnerInfo, console: BecConsole) -> None:
|
||||||
|
info.instance = _BecTermClass()
|
||||||
|
info.initialized = False
|
||||||
|
info.owner_console_id = console.console_id
|
||||||
|
info.registered_console_ids.add(console.console_id)
|
||||||
|
info.persist_session = info.persist_session or console.persist_terminal_session
|
||||||
|
|
||||||
|
def _delete_terminal_info(self, info: _TerminalOwnerInfo) -> None:
|
||||||
|
if self._is_valid_qobject(info.instance):
|
||||||
|
info.instance.deleteLater() # type: ignore[union-attr]
|
||||||
|
info.instance = None
|
||||||
|
if self._is_valid_qobject(info.fallback_holder):
|
||||||
|
info.fallback_holder.deleteLater()
|
||||||
|
info.fallback_holder = None
|
||||||
|
|
||||||
|
def _parking_parent(
|
||||||
|
self,
|
||||||
|
info: _TerminalOwnerInfo,
|
||||||
|
console: BecConsole | None = None,
|
||||||
|
*,
|
||||||
|
avoid_console: bool = False,
|
||||||
|
) -> QWidget | None:
|
||||||
|
for console_id in info.registered_console_ids:
|
||||||
|
candidate = self._consoles.get(console_id)
|
||||||
|
if candidate is None or candidate is console:
|
||||||
|
continue
|
||||||
|
if self._is_valid_qobject(candidate):
|
||||||
|
return candidate._term_holder
|
||||||
|
|
||||||
|
if console is None or not self._is_valid_qobject(console):
|
||||||
|
return None
|
||||||
|
|
||||||
|
window = console.window()
|
||||||
|
if window is not None and window is not console and self._is_valid_qobject(window):
|
||||||
|
return window
|
||||||
|
|
||||||
|
if not avoid_console:
|
||||||
|
return console._term_holder
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _fallback_holder(
|
||||||
|
self,
|
||||||
|
info: _TerminalOwnerInfo,
|
||||||
|
console: BecConsole | None = None,
|
||||||
|
*,
|
||||||
|
avoid_console: bool = False,
|
||||||
|
) -> QWidget:
|
||||||
|
if not self._is_valid_qobject(info.fallback_holder):
|
||||||
|
info.fallback_holder = QWidget(
|
||||||
|
parent=self._parking_parent(info, console, avoid_console=avoid_console)
|
||||||
|
)
|
||||||
|
info.fallback_holder.setObjectName(f"_bec_console_terminal_holder_{info.terminal_id}")
|
||||||
|
info.fallback_holder.hide()
|
||||||
|
return info.fallback_holder
|
||||||
|
|
||||||
|
def _park_terminal(
|
||||||
|
self,
|
||||||
|
info: _TerminalOwnerInfo,
|
||||||
|
console: BecConsole | None = None,
|
||||||
|
*,
|
||||||
|
avoid_console: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if not self._is_valid_qobject(info.instance):
|
||||||
|
return
|
||||||
|
|
||||||
|
parent = self._parking_parent(info, console, avoid_console=avoid_console)
|
||||||
|
if parent is None and info.persist_session:
|
||||||
|
parent = self._fallback_holder(info, console, avoid_console=avoid_console)
|
||||||
|
|
||||||
|
info.instance.hide() # type: ignore[union-attr]
|
||||||
|
info.instance.setParent(parent) # type: ignore[union-attr]
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Delete every tracked terminal and holder."""
|
||||||
|
for info in list(self._terminal_registry.values()):
|
||||||
|
self._delete_terminal_info(info)
|
||||||
|
self._terminal_registry.clear()
|
||||||
|
self._consoles.clear()
|
||||||
|
|
||||||
|
def register(self, console: BecConsole):
|
||||||
|
"""
|
||||||
|
Register an instance of BecConsole. If there is already a terminal with the associated
|
||||||
|
terminal_id, this does not automatically grant ownership.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
console (BecConsole): The instance to register.
|
||||||
|
"""
|
||||||
|
self._connect_app_cleanup()
|
||||||
|
self._consoles[console.console_id] = console
|
||||||
|
console_id, terminal_id = console.console_id, console.terminal_id
|
||||||
|
term_info = self._terminal_registry.get(terminal_id)
|
||||||
|
if term_info is None:
|
||||||
|
self._terminal_registry[terminal_id] = self._new_terminal_info(console)
|
||||||
|
return
|
||||||
|
|
||||||
|
term_info.persist_session = term_info.persist_session or console.persist_terminal_session
|
||||||
|
had_registered_consoles = bool(term_info.registered_console_ids)
|
||||||
|
term_info.registered_console_ids.add(console_id)
|
||||||
|
if not self._is_valid_qobject(term_info.instance):
|
||||||
|
self._replace_terminal(term_info, console)
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
term_info.owner_console_id is not None
|
||||||
|
and term_info.owner_console_id not in self._consoles
|
||||||
|
):
|
||||||
|
term_info.owner_console_id = None
|
||||||
|
if term_info.owner_console_id is None and not had_registered_consoles:
|
||||||
|
term_info.owner_console_id = console_id
|
||||||
|
logger.info(f"Registered new console {console_id} for terminal {terminal_id}")
|
||||||
|
|
||||||
|
def unregister(self, console: BecConsole):
|
||||||
|
"""
|
||||||
|
Unregister an instance of BecConsole.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
console (BecConsole): The instance to unregister.
|
||||||
|
"""
|
||||||
|
console_id, terminal_id = console.console_id, console.terminal_id
|
||||||
|
if console_id in self._consoles:
|
||||||
|
del self._consoles[console_id]
|
||||||
|
if (term_info := self._terminal_registry.get(terminal_id)) is None:
|
||||||
|
return
|
||||||
|
detached = console._detach_terminal_widget(term_info.instance)
|
||||||
|
if console_id in term_info.registered_console_ids:
|
||||||
|
term_info.registered_console_ids.remove(console_id)
|
||||||
|
if term_info.owner_console_id == console_id:
|
||||||
|
term_info.owner_console_id = None
|
||||||
|
if not term_info.registered_console_ids:
|
||||||
|
if term_info.persist_session and self._is_valid_qobject(term_info.instance):
|
||||||
|
self._park_terminal(term_info, console, avoid_console=True)
|
||||||
|
logger.info(f"Unregistered console {console_id} for terminal {terminal_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._delete_terminal_info(term_info)
|
||||||
|
del self._terminal_registry[terminal_id]
|
||||||
|
elif detached:
|
||||||
|
self._park_terminal(term_info, console, avoid_console=True)
|
||||||
|
|
||||||
|
logger.info(f"Unregistered console {console_id} for terminal {terminal_id}")
|
||||||
|
|
||||||
|
def is_owner(self, console: BecConsole):
|
||||||
|
"""Returns true if the given console is the owner of its terminal"""
|
||||||
|
if console not in self._consoles.values():
|
||||||
|
return False
|
||||||
|
if (info := self._terminal_registry.get(console.terminal_id)) is None:
|
||||||
|
logger.warning(f"Console {console.console_id} references an unknown terminal!")
|
||||||
|
return False
|
||||||
|
if not self._is_valid_qobject(info.instance):
|
||||||
|
return False
|
||||||
|
return info.owner_console_id == console.console_id
|
||||||
|
|
||||||
|
def take_ownership(self, console: BecConsole) -> BecTerminal | None:
|
||||||
|
"""
|
||||||
|
Transfer ownership of a terminal to the given console.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
console: the console which wishes to take ownership of its associated terminal.
|
||||||
|
Returns:
|
||||||
|
BecTerminal | None: The instance if ownership transfer was successful, None otherwise.
|
||||||
|
"""
|
||||||
|
console_id, terminal_id = console.console_id, console.terminal_id
|
||||||
|
|
||||||
|
if terminal_id not in self._terminal_registry:
|
||||||
|
self.register(console)
|
||||||
|
|
||||||
|
instance_info = self._terminal_registry[terminal_id]
|
||||||
|
if not self._is_valid_qobject(instance_info.instance):
|
||||||
|
self._replace_terminal(instance_info, console)
|
||||||
|
if (old_owner_console_ide := instance_info.owner_console_id) is not None:
|
||||||
|
if (
|
||||||
|
old_owner_console_ide != console_id
|
||||||
|
and (old_owner := self._consoles.get(old_owner_console_ide)) is not None
|
||||||
|
):
|
||||||
|
old_owner.yield_ownership() # call this on the old owner to make sure it is updated
|
||||||
|
instance_info.owner_console_id = console_id
|
||||||
|
instance_info.registered_console_ids.add(console_id)
|
||||||
|
logger.info(f"Transferred ownership of terminal {terminal_id} to {console_id}")
|
||||||
|
return instance_info.instance
|
||||||
|
|
||||||
|
def try_get_term(self, console: BecConsole) -> BecTerminal | None:
|
||||||
|
"""
|
||||||
|
Return the terminal instance if the requesting console is the owner
|
||||||
|
|
||||||
|
Args:
|
||||||
|
console: the requesting console.
|
||||||
|
Returns:
|
||||||
|
BecTerminal | None: The instance if the console is the owner, None otherwise.
|
||||||
|
"""
|
||||||
|
console_id, terminal_id = console.console_id, console.terminal_id
|
||||||
|
logger.debug(f"checking term for {console_id}")
|
||||||
|
if terminal_id not in self._terminal_registry:
|
||||||
|
logger.warning(f"Terminal {terminal_id} not found in registry")
|
||||||
|
return None
|
||||||
|
|
||||||
|
instance_info = self._terminal_registry[terminal_id]
|
||||||
|
if not self._is_valid_qobject(instance_info.instance):
|
||||||
|
if instance_info.owner_console_id == console_id:
|
||||||
|
self._replace_terminal(instance_info, console)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
if instance_info.owner_console_id == console_id:
|
||||||
|
return instance_info.instance
|
||||||
|
|
||||||
|
def yield_ownership(self, console: BecConsole):
|
||||||
|
"""
|
||||||
|
Yield ownership of an instance without destroying it. The instance remains in the
|
||||||
|
registry with no owner, available for another widget to claim.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
console (BecConsole): The console which wishes to yield ownership of its associated terminal.
|
||||||
|
"""
|
||||||
|
console_id, terminal_id = console.console_id, console.terminal_id
|
||||||
|
logger.debug(f"Console {console_id} attempted to yield ownership")
|
||||||
|
if console_id not in self._consoles or terminal_id not in self._terminal_registry:
|
||||||
|
return
|
||||||
|
|
||||||
|
term_info = self._terminal_registry[terminal_id]
|
||||||
|
if term_info.owner_console_id != console_id:
|
||||||
|
logger.debug(f"But it was not the owner, which was {term_info.owner_console_id}!")
|
||||||
|
return
|
||||||
|
term_info.owner_console_id = None
|
||||||
|
console._detach_terminal_widget(term_info.instance)
|
||||||
|
self._park_terminal(term_info, console)
|
||||||
|
|
||||||
|
def should_initialize(self, console: BecConsole) -> bool:
|
||||||
|
"""Return true if the console should send its startup command to the terminal."""
|
||||||
|
info = self._terminal_registry.get(console.terminal_id)
|
||||||
|
if info is None:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
info.owner_console_id == console.console_id
|
||||||
|
and not info.initialized
|
||||||
|
and self._is_valid_qobject(info.instance)
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_initialized(self, console: BecConsole) -> None:
|
||||||
|
info = self._terminal_registry.get(console.terminal_id)
|
||||||
|
if info is not None and info.owner_console_id == console.console_id:
|
||||||
|
info.initialized = True
|
||||||
|
|
||||||
|
def owner_is_visible(self, term_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the owner of an instance is currently visible.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
term_id (str): The terminal ID to check.
|
||||||
|
Returns:
|
||||||
|
bool: True if the owner is visible, False otherwise.
|
||||||
|
"""
|
||||||
|
instance_info = self._terminal_registry.get(term_id)
|
||||||
|
if (
|
||||||
|
instance_info is None
|
||||||
|
or instance_info.owner_console_id is None
|
||||||
|
or not self._is_valid_qobject(instance_info.instance)
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if (owner := self._consoles.get(instance_info.owner_console_id)) is None:
|
||||||
|
return False
|
||||||
|
return owner.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
_bec_console_registry = BecConsoleRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
class _Overlay(QWidget):
|
||||||
|
def __init__(self, console: BecConsole):
|
||||||
|
super().__init__(parent=console)
|
||||||
|
self._console = console
|
||||||
|
|
||||||
|
def mousePressEvent(self, event: QMouseEvent) -> None:
|
||||||
|
if event.button() == Qt.MouseButton.LeftButton:
|
||||||
|
self._console.take_terminal_ownership()
|
||||||
|
event.accept()
|
||||||
|
return
|
||||||
|
return super().mousePressEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
class BecConsole(BECWidget, QWidget):
|
||||||
|
"""A console widget with access to a shared registry of terminals, such that instances can be moved around."""
|
||||||
|
|
||||||
|
_js_callback = Signal(bool)
|
||||||
|
initialized = Signal()
|
||||||
|
|
||||||
|
PLUGIN = True
|
||||||
|
ICON_NAME = "terminal"
|
||||||
|
persist_terminal_session = False
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent=None,
|
||||||
|
config=None,
|
||||||
|
client=None,
|
||||||
|
gui_id=None,
|
||||||
|
startup_cmd: str | None = None,
|
||||||
|
terminal_id: str | None = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||||
|
self._mode = ConsoleMode.INACTIVE
|
||||||
|
self._startup_cmd = startup_cmd
|
||||||
|
self._is_initialized = False
|
||||||
|
self.terminal_id = terminal_id or str(uuid4())
|
||||||
|
self.console_id = self.gui_id
|
||||||
|
self.term: BecTerminal | None = None # Will be set in _set_up_instance
|
||||||
|
|
||||||
|
self._set_up_instance()
|
||||||
|
|
||||||
|
def _set_up_instance(self):
|
||||||
|
"""
|
||||||
|
Set up the web instance and UI elements.
|
||||||
|
"""
|
||||||
|
self._stacked_layout = QStackedLayout()
|
||||||
|
# self._stacked_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
|
||||||
|
self._term_holder = QWidget()
|
||||||
|
self._term_layout = QVBoxLayout()
|
||||||
|
self._term_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self._term_holder.setLayout(self._term_layout)
|
||||||
|
|
||||||
|
self.setLayout(self._stacked_layout)
|
||||||
|
|
||||||
|
# prepare overlay
|
||||||
|
self._overlay = _Overlay(self)
|
||||||
|
layout = QVBoxLayout(self._overlay)
|
||||||
|
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
label = QLabel("Click to activate terminal", self._overlay)
|
||||||
|
layout.addWidget(label)
|
||||||
|
|
||||||
|
self._stacked_layout.addWidget(self._term_holder)
|
||||||
|
self._stacked_layout.addWidget(self._overlay)
|
||||||
|
|
||||||
|
# will create a new terminal instance if there isn't already one for this ID
|
||||||
|
_bec_console_registry.register(self)
|
||||||
|
self._infer_mode()
|
||||||
|
self._ensure_startup_started()
|
||||||
|
|
||||||
|
def _infer_mode(self):
|
||||||
|
self.term = _bec_console_registry.try_get_term(self)
|
||||||
|
if self.term:
|
||||||
|
self._set_mode(ConsoleMode.ACTIVE)
|
||||||
|
elif self.isHidden():
|
||||||
|
self._set_mode(ConsoleMode.HIDDEN)
|
||||||
|
else:
|
||||||
|
self._set_mode(ConsoleMode.INACTIVE)
|
||||||
|
|
||||||
|
def _set_mode(self, mode: ConsoleMode):
|
||||||
|
"""
|
||||||
|
Set the mode of the web console.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode (ConsoleMode): The mode to set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
match mode:
|
||||||
|
case ConsoleMode.ACTIVE:
|
||||||
|
if self.term:
|
||||||
|
if self._term_layout.indexOf(self.term) == -1: # type: ignore[arg-type]
|
||||||
|
self._term_layout.addWidget(self.term) # type: ignore # BecTerminal is QWidget
|
||||||
|
self.term.show() # type: ignore[attr-defined]
|
||||||
|
self._stacked_layout.setCurrentIndex(0)
|
||||||
|
self._mode = mode
|
||||||
|
else:
|
||||||
|
self._stacked_layout.setCurrentIndex(1)
|
||||||
|
self._mode = ConsoleMode.INACTIVE
|
||||||
|
case ConsoleMode.INACTIVE:
|
||||||
|
self._stacked_layout.setCurrentIndex(1)
|
||||||
|
self._mode = mode
|
||||||
|
case ConsoleMode.HIDDEN:
|
||||||
|
self._stacked_layout.setCurrentIndex(1)
|
||||||
|
self._mode = mode
|
||||||
|
|
||||||
|
@property
|
||||||
|
def startup_cmd(self):
|
||||||
|
"""
|
||||||
|
Get the startup command for the web console.
|
||||||
|
"""
|
||||||
|
return self._startup_cmd
|
||||||
|
|
||||||
|
@startup_cmd.setter
|
||||||
|
def startup_cmd(self, cmd: str | None):
|
||||||
|
"""
|
||||||
|
Set the startup command for the console.
|
||||||
|
"""
|
||||||
|
self._startup_cmd = cmd
|
||||||
|
|
||||||
|
def write(self, data: str, send_return: bool = True):
|
||||||
|
"""
|
||||||
|
Send data to the console
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data (str): The data to send.
|
||||||
|
send_return (bool): Whether to send a return after the data.
|
||||||
|
"""
|
||||||
|
if self.term:
|
||||||
|
self.term.write(data, send_return)
|
||||||
|
|
||||||
|
def _ensure_startup_started(self):
|
||||||
|
if not self.startup_cmd or not _bec_console_registry.should_initialize(self):
|
||||||
|
return
|
||||||
|
self.write(self.startup_cmd, True)
|
||||||
|
_bec_console_registry.mark_initialized(self)
|
||||||
|
|
||||||
|
def _detach_terminal_widget(self, term: BecTerminal | None) -> bool:
|
||||||
|
if term is None or not BecConsoleRegistry._is_valid_qobject(term):
|
||||||
|
if self.term is term:
|
||||||
|
self.term = None
|
||||||
|
return False
|
||||||
|
|
||||||
|
is_child = self.isAncestorOf(term) # type: ignore[arg-type]
|
||||||
|
if self._term_layout.indexOf(term) != -1: # type: ignore[arg-type]
|
||||||
|
self._term_layout.removeWidget(term) # type: ignore[arg-type]
|
||||||
|
is_child = True
|
||||||
|
if is_child:
|
||||||
|
term.hide() # type: ignore[attr-defined]
|
||||||
|
term.setParent(None) # type: ignore[attr-defined]
|
||||||
|
if self.term is term:
|
||||||
|
self.term = None
|
||||||
|
return is_child
|
||||||
|
|
||||||
|
def take_terminal_ownership(self):
|
||||||
|
"""
|
||||||
|
Take ownership of a web instance from the registry. This will transfer the instance
|
||||||
|
from its current owner (if any) to this widget.
|
||||||
|
"""
|
||||||
|
# Get the instance from registry
|
||||||
|
self.term = _bec_console_registry.take_ownership(self)
|
||||||
|
self._infer_mode()
|
||||||
|
self._ensure_startup_started()
|
||||||
|
if self._mode == ConsoleMode.ACTIVE:
|
||||||
|
logger.debug(f"Widget {self.gui_id} took ownership of instance {self.terminal_id}")
|
||||||
|
|
||||||
|
def yield_ownership(self):
|
||||||
|
"""
|
||||||
|
Yield ownership of the instance. The instance remains in the registry with no owner,
|
||||||
|
available for another widget to claim. This is automatically called when the
|
||||||
|
widget becomes hidden.
|
||||||
|
"""
|
||||||
|
_bec_console_registry.yield_ownership(self)
|
||||||
|
self._infer_mode()
|
||||||
|
if self._mode != ConsoleMode.ACTIVE:
|
||||||
|
logger.debug(f"Widget {self.gui_id} yielded ownership of instance {self.terminal_id}")
|
||||||
|
|
||||||
|
def hideEvent(self, event):
|
||||||
|
"""Called when the widget is hidden. Automatically yields ownership."""
|
||||||
|
self.yield_ownership()
|
||||||
|
super().hideEvent(event)
|
||||||
|
|
||||||
|
def showEvent(self, event):
|
||||||
|
"""Called when the widget is shown. Updates UI state based on ownership."""
|
||||||
|
super().showEvent(event)
|
||||||
|
if not _bec_console_registry.is_owner(self):
|
||||||
|
if not _bec_console_registry.owner_is_visible(self.terminal_id):
|
||||||
|
self.take_terminal_ownership()
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
"""Unregister this console on destruction."""
|
||||||
|
_bec_console_registry.unregister(self)
|
||||||
|
super().cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class BECShell(BecConsole):
|
||||||
|
"""
|
||||||
|
A BecConsole pre-configured to run the BEC shell.
|
||||||
|
We cannot simply expose the web console properties to Qt as we need to have a deterministic
|
||||||
|
startup behavior for sharing the same shell instance across multiple widgets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ICON_NAME = "hub"
|
||||||
|
persist_terminal_session = True
|
||||||
|
|
||||||
|
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
||||||
|
super().__init__(
|
||||||
|
parent=parent,
|
||||||
|
config=config,
|
||||||
|
client=client,
|
||||||
|
gui_id=gui_id,
|
||||||
|
terminal_id="bec_shell",
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def startup_cmd(self):
|
||||||
|
"""
|
||||||
|
Get the startup command for the BEC shell.
|
||||||
|
"""
|
||||||
|
if self.bec_dispatcher.cli_server is None:
|
||||||
|
return "bec --nogui"
|
||||||
|
return f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}"
|
||||||
|
|
||||||
|
@startup_cmd.setter
|
||||||
|
def startup_cmd(self, cmd: str | None): ...
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
import sys
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
widget = QTabWidget()
|
||||||
|
|
||||||
|
# Create two consoles with different unique_ids
|
||||||
|
bec_console_1a = BecConsole(startup_cmd="htop", gui_id="console_1_a", terminal_id="terminal_1")
|
||||||
|
bec_console_1b = BecConsole(startup_cmd="htop", gui_id="console_1_b", terminal_id="terminal_1")
|
||||||
|
bec_console_1 = QWidget()
|
||||||
|
bec_console_1_layout = QHBoxLayout(bec_console_1)
|
||||||
|
bec_console_1_layout.addWidget(bec_console_1a)
|
||||||
|
bec_console_1_layout.addWidget(bec_console_1b)
|
||||||
|
bec_console2 = BECShell()
|
||||||
|
bec_console3 = BecConsole(gui_id="console_3", terminal_id="terminal_1")
|
||||||
|
widget.addTab(bec_console_1, "Console 1")
|
||||||
|
widget.addTab(bec_console2, "Console 2 - BEC Shell")
|
||||||
|
widget.addTab(bec_console3, "Console 3 -- mirror of Console 1")
|
||||||
|
widget.show()
|
||||||
|
|
||||||
|
widget.resize(800, 600)
|
||||||
|
|
||||||
|
sys.exit(app.exec_())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{'files': ['bec_console.py']}
|
||||||
+9
-9
@@ -5,17 +5,17 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
|||||||
from qtpy.QtWidgets import QWidget
|
from qtpy.QtWidgets import QWidget
|
||||||
|
|
||||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||||
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
|
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole
|
||||||
|
|
||||||
DOM_XML = """
|
DOM_XML = """
|
||||||
<ui language='c++'>
|
<ui language='c++'>
|
||||||
<widget class='WebConsole' name='web_console'>
|
<widget class='BecConsole' name='bec_console'>
|
||||||
</widget>
|
</widget>
|
||||||
</ui>
|
</ui>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
class BecConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._form_editor = None
|
self._form_editor = None
|
||||||
@@ -23,20 +23,20 @@ class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
|||||||
def createWidget(self, parent):
|
def createWidget(self, parent):
|
||||||
if parent is None:
|
if parent is None:
|
||||||
return QWidget()
|
return QWidget()
|
||||||
t = WebConsole(parent)
|
t = BecConsole(parent)
|
||||||
return t
|
return t
|
||||||
|
|
||||||
def domXml(self):
|
def domXml(self):
|
||||||
return DOM_XML
|
return DOM_XML
|
||||||
|
|
||||||
def group(self):
|
def group(self):
|
||||||
return "BEC Developer"
|
return ""
|
||||||
|
|
||||||
def icon(self):
|
def icon(self):
|
||||||
return designer_material_icon(WebConsole.ICON_NAME)
|
return designer_material_icon(BecConsole.ICON_NAME)
|
||||||
|
|
||||||
def includeFile(self):
|
def includeFile(self):
|
||||||
return "web_console"
|
return "bec_console"
|
||||||
|
|
||||||
def initialize(self, form_editor):
|
def initialize(self, form_editor):
|
||||||
self._form_editor = form_editor
|
self._form_editor = form_editor
|
||||||
@@ -48,10 +48,10 @@ class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
|||||||
return self._form_editor is not None
|
return self._form_editor is not None
|
||||||
|
|
||||||
def name(self):
|
def name(self):
|
||||||
return "WebConsole"
|
return "BecConsole"
|
||||||
|
|
||||||
def toolTip(self):
|
def toolTip(self):
|
||||||
return ""
|
return "A console widget with access to a shared registry of terminals, such that instances can be moved around."
|
||||||
|
|
||||||
def whatsThis(self):
|
def whatsThis(self):
|
||||||
return self.toolTip()
|
return self.toolTip()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{'files': ['bec_console.py']}
|
||||||
+1
-1
@@ -5,7 +5,7 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
|||||||
from qtpy.QtWidgets import QWidget
|
from qtpy.QtWidgets import QWidget
|
||||||
|
|
||||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||||
from bec_widgets.widgets.editors.web_console.web_console import BECShell
|
from bec_widgets.widgets.editors.bec_console.bec_console import BECShell
|
||||||
|
|
||||||
DOM_XML = """
|
DOM_XML = """
|
||||||
<ui language='c++'>
|
<ui language='c++'>
|
||||||
+2
-2
@@ -6,9 +6,9 @@ def main(): # pragma: no cover
|
|||||||
return
|
return
|
||||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||||
|
|
||||||
from bec_widgets.widgets.editors.web_console.web_console_plugin import WebConsolePlugin
|
from bec_widgets.widgets.editors.bec_console.bec_console_plugin import BecConsolePlugin
|
||||||
|
|
||||||
QPyDesignerCustomWidgetCollection.addCustomWidget(WebConsolePlugin())
|
QPyDesignerCustomWidgetCollection.addCustomWidget(BecConsolePlugin())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
+1
-1
@@ -6,7 +6,7 @@ def main(): # pragma: no cover
|
|||||||
return
|
return
|
||||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||||
|
|
||||||
from bec_widgets.widgets.editors.web_console.bec_shell_plugin import BECShellPlugin
|
from bec_widgets.widgets.editors.bec_console.bec_shell_plugin import BECShellPlugin
|
||||||
|
|
||||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECShellPlugin())
|
QPyDesignerCustomWidgetCollection.addCustomWidget(BECShellPlugin())
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{'files': ['web_console.py']}
|
|
||||||
@@ -1,705 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import enum
|
|
||||||
import json
|
|
||||||
import secrets
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
|
|
||||||
from bec_lib.logger import bec_logger
|
|
||||||
from louie.saferef import safe_ref
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from qtpy.QtCore import Qt, QTimer, QUrl, Signal, qInstallMessageHandler
|
|
||||||
from qtpy.QtGui import QMouseEvent, QResizeEvent
|
|
||||||
from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView
|
|
||||||
from qtpy.QtWidgets import QApplication, QLabel, QTabWidget, QVBoxLayout, QWidget
|
|
||||||
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
|
||||||
|
|
||||||
logger = bec_logger.logger
|
|
||||||
|
|
||||||
|
|
||||||
class ConsoleMode(str, enum.Enum):
|
|
||||||
ACTIVE = "active"
|
|
||||||
INACTIVE = "inactive"
|
|
||||||
HIDDEN = "hidden"
|
|
||||||
|
|
||||||
|
|
||||||
class PageOwnerInfo(BaseModel):
|
|
||||||
owner_gui_id: str | None = None
|
|
||||||
widget_ids: list[str] = []
|
|
||||||
page: QWebEnginePage | None = None
|
|
||||||
initialized: bool = False
|
|
||||||
|
|
||||||
model_config = {"arbitrary_types_allowed": True}
|
|
||||||
|
|
||||||
|
|
||||||
class WebConsoleRegistry:
|
|
||||||
"""
|
|
||||||
A registry for the WebConsole class to manage its instances.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""
|
|
||||||
Initialize the registry.
|
|
||||||
"""
|
|
||||||
self._instances = {}
|
|
||||||
self._server_process = None
|
|
||||||
self._server_port = None
|
|
||||||
self._token = secrets.token_hex(16)
|
|
||||||
self._page_registry: dict[str, PageOwnerInfo] = {}
|
|
||||||
|
|
||||||
def register(self, instance: WebConsole):
|
|
||||||
"""
|
|
||||||
Register an instance of WebConsole.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance (WebConsole): The instance to register.
|
|
||||||
"""
|
|
||||||
self._instances[instance.gui_id] = safe_ref(instance)
|
|
||||||
self.cleanup()
|
|
||||||
|
|
||||||
if instance._unique_id:
|
|
||||||
self._register_page(instance)
|
|
||||||
|
|
||||||
if self._server_process is None:
|
|
||||||
# Start the ttyd server if not already running
|
|
||||||
self.start_ttyd()
|
|
||||||
|
|
||||||
def start_ttyd(self, use_zsh: bool | None = None):
|
|
||||||
"""
|
|
||||||
Start the ttyd server
|
|
||||||
ttyd -q -W -t 'theme={"background": "black"}' zsh
|
|
||||||
|
|
||||||
Args:
|
|
||||||
use_zsh (bool): Whether to use zsh or bash. If None, it will try to detect if zsh is available.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# First, check if ttyd is installed
|
|
||||||
try:
|
|
||||||
subprocess.run(["ttyd", "--version"], check=True, stdout=subprocess.PIPE)
|
|
||||||
except FileNotFoundError:
|
|
||||||
# pylint: disable=raise-missing-from
|
|
||||||
raise RuntimeError("ttyd is not installed. Please install it first.")
|
|
||||||
|
|
||||||
if use_zsh is None:
|
|
||||||
# Check if we can use zsh
|
|
||||||
try:
|
|
||||||
subprocess.run(["zsh", "--version"], check=True, stdout=subprocess.PIPE)
|
|
||||||
use_zsh = True
|
|
||||||
except FileNotFoundError:
|
|
||||||
use_zsh = False
|
|
||||||
|
|
||||||
command = [
|
|
||||||
"ttyd",
|
|
||||||
"-p",
|
|
||||||
"0",
|
|
||||||
"-W",
|
|
||||||
"-t",
|
|
||||||
'theme={"background": "black"}',
|
|
||||||
"-c",
|
|
||||||
f"user:{self._token}",
|
|
||||||
]
|
|
||||||
if use_zsh:
|
|
||||||
command.append("zsh")
|
|
||||||
else:
|
|
||||||
command.append("bash")
|
|
||||||
|
|
||||||
# Start the ttyd server
|
|
||||||
self._server_process = subprocess.Popen(
|
|
||||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
|
||||||
)
|
|
||||||
|
|
||||||
self._wait_for_server_port()
|
|
||||||
|
|
||||||
self._server_process.stdout.close()
|
|
||||||
self._server_process.stderr.close()
|
|
||||||
|
|
||||||
def _wait_for_server_port(self, timeout: float = 10):
|
|
||||||
"""
|
|
||||||
Wait for the ttyd server to start and get the port number.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timeout (float): The timeout in seconds to wait for the server to start.
|
|
||||||
"""
|
|
||||||
start_time = time.time()
|
|
||||||
while True:
|
|
||||||
output = self._server_process.stderr.readline()
|
|
||||||
if output == b"" and self._server_process.poll() is not None:
|
|
||||||
break
|
|
||||||
if not output:
|
|
||||||
continue
|
|
||||||
|
|
||||||
output = output.decode("utf-8").strip()
|
|
||||||
if "Listening on" in output:
|
|
||||||
# Extract the port number from the output
|
|
||||||
self._server_port = int(output.split(":")[-1])
|
|
||||||
logger.info(f"ttyd server started on port {self._server_port}")
|
|
||||||
break
|
|
||||||
if time.time() - start_time > timeout:
|
|
||||||
raise TimeoutError(
|
|
||||||
"Timeout waiting for ttyd server to start. Please check if ttyd is installed and available in your PATH."
|
|
||||||
)
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
"""
|
|
||||||
Clean up the registry by removing any instances that are no longer valid.
|
|
||||||
"""
|
|
||||||
for gui_id, weak_ref in list(self._instances.items()):
|
|
||||||
if weak_ref() is None:
|
|
||||||
del self._instances[gui_id]
|
|
||||||
|
|
||||||
if not self._instances and self._server_process:
|
|
||||||
# If no instances are left, terminate the server process
|
|
||||||
self._server_process.terminate()
|
|
||||||
self._server_process = None
|
|
||||||
self._server_port = None
|
|
||||||
logger.info("ttyd server terminated")
|
|
||||||
|
|
||||||
def unregister(self, instance: WebConsole):
|
|
||||||
"""
|
|
||||||
Unregister an instance of WebConsole.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance (WebConsole): The instance to unregister.
|
|
||||||
"""
|
|
||||||
if instance.gui_id in self._instances:
|
|
||||||
del self._instances[instance.gui_id]
|
|
||||||
|
|
||||||
if instance._unique_id:
|
|
||||||
self._unregister_page(instance._unique_id, instance.gui_id)
|
|
||||||
|
|
||||||
self.cleanup()
|
|
||||||
|
|
||||||
def _register_page(self, instance: WebConsole):
|
|
||||||
"""
|
|
||||||
Register a page in the registry. Please note that this does not transfer ownership
|
|
||||||
for already existing pages; it simply records which widget currently owns the page.
|
|
||||||
Use transfer_page_ownership to change ownership.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance (WebConsole): The instance to register.
|
|
||||||
"""
|
|
||||||
|
|
||||||
unique_id = instance._unique_id
|
|
||||||
gui_id = instance.gui_id
|
|
||||||
|
|
||||||
if unique_id is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
if unique_id not in self._page_registry:
|
|
||||||
page = BECWebEnginePage()
|
|
||||||
page.authenticationRequired.connect(instance._authenticate)
|
|
||||||
self._page_registry[unique_id] = PageOwnerInfo(
|
|
||||||
owner_gui_id=gui_id, widget_ids=[gui_id], page=page
|
|
||||||
)
|
|
||||||
logger.info(f"Registered new page {unique_id} for {gui_id}")
|
|
||||||
return
|
|
||||||
|
|
||||||
if gui_id not in self._page_registry[unique_id].widget_ids:
|
|
||||||
self._page_registry[unique_id].widget_ids.append(gui_id)
|
|
||||||
|
|
||||||
def _unregister_page(self, unique_id: str, gui_id: str):
|
|
||||||
"""
|
|
||||||
Unregister a page from the registry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
unique_id (str): The unique identifier for the page.
|
|
||||||
gui_id (str): The GUI ID of the widget.
|
|
||||||
"""
|
|
||||||
if unique_id not in self._page_registry:
|
|
||||||
return
|
|
||||||
page_info = self._page_registry[unique_id]
|
|
||||||
if gui_id in page_info.widget_ids:
|
|
||||||
page_info.widget_ids.remove(gui_id)
|
|
||||||
if page_info.owner_gui_id == gui_id:
|
|
||||||
page_info.owner_gui_id = None
|
|
||||||
if not page_info.widget_ids:
|
|
||||||
if page_info.page:
|
|
||||||
page_info.page.deleteLater()
|
|
||||||
del self._page_registry[unique_id]
|
|
||||||
|
|
||||||
logger.info(f"Unregistered page {unique_id} for {gui_id}")
|
|
||||||
|
|
||||||
def get_page_info(self, unique_id: str) -> PageOwnerInfo | None:
|
|
||||||
"""
|
|
||||||
Get a page from the registry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
unique_id (str): The unique identifier for the page.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
PageOwnerInfo | None: The page info if found, None otherwise.
|
|
||||||
"""
|
|
||||||
if unique_id not in self._page_registry:
|
|
||||||
return None
|
|
||||||
return self._page_registry[unique_id]
|
|
||||||
|
|
||||||
def take_page_ownership(self, unique_id: str, new_owner_gui_id: str) -> QWebEnginePage | None:
|
|
||||||
"""
|
|
||||||
Transfer ownership of a page to a new owner.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
unique_id (str): The unique identifier for the page.
|
|
||||||
new_owner_gui_id (str): The GUI ID of the new owner.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
QWebEnginePage | None: The page if ownership transfer was successful, None otherwise.
|
|
||||||
"""
|
|
||||||
if unique_id not in self._page_registry:
|
|
||||||
logger.warning(f"Page {unique_id} not found in registry")
|
|
||||||
return None
|
|
||||||
|
|
||||||
page_info = self._page_registry[unique_id]
|
|
||||||
old_owner_gui_id = page_info.owner_gui_id
|
|
||||||
if old_owner_gui_id:
|
|
||||||
old_owner_ref = self._instances.get(old_owner_gui_id)
|
|
||||||
if old_owner_ref:
|
|
||||||
old_owner_instance = old_owner_ref()
|
|
||||||
if old_owner_instance:
|
|
||||||
old_owner_instance.yield_ownership()
|
|
||||||
page_info.owner_gui_id = new_owner_gui_id
|
|
||||||
|
|
||||||
logger.info(f"Transferred ownership of page {unique_id} to {new_owner_gui_id}")
|
|
||||||
return page_info.page
|
|
||||||
|
|
||||||
def yield_ownership(self, gui_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Yield ownership of a page without destroying it. The page remains in the
|
|
||||||
registry with no owner, available for another widget to claim.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
gui_id (str): The GUI ID of the widget yielding ownership.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if ownership was yielded, False otherwise.
|
|
||||||
"""
|
|
||||||
if gui_id not in self._instances:
|
|
||||||
return False
|
|
||||||
|
|
||||||
instance = self._instances[gui_id]()
|
|
||||||
if instance is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
unique_id = instance._unique_id
|
|
||||||
if unique_id is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if unique_id not in self._page_registry:
|
|
||||||
return False
|
|
||||||
|
|
||||||
page_owner_info = self._page_registry[unique_id]
|
|
||||||
if page_owner_info.owner_gui_id != gui_id:
|
|
||||||
return False
|
|
||||||
|
|
||||||
page_owner_info.owner_gui_id = None
|
|
||||||
return True
|
|
||||||
|
|
||||||
def owner_is_visible(self, unique_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if the owner of a page is currently visible.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
unique_id (str): The unique identifier for the page.
|
|
||||||
Returns:
|
|
||||||
bool: True if the owner is visible, False otherwise.
|
|
||||||
"""
|
|
||||||
page_info = self.get_page_info(unique_id)
|
|
||||||
if page_info is None or page_info.owner_gui_id is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
owner_ref = self._instances.get(page_info.owner_gui_id)
|
|
||||||
if owner_ref is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
owner_instance = owner_ref()
|
|
||||||
if owner_instance is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return owner_instance.isVisible()
|
|
||||||
|
|
||||||
|
|
||||||
_web_console_registry = WebConsoleRegistry()
|
|
||||||
|
|
||||||
|
|
||||||
def suppress_qt_messages(type_, context, msg):
|
|
||||||
if context.category in ["js", "default"]:
|
|
||||||
return
|
|
||||||
print(msg)
|
|
||||||
|
|
||||||
|
|
||||||
qInstallMessageHandler(suppress_qt_messages)
|
|
||||||
|
|
||||||
|
|
||||||
class BECWebEnginePage(QWebEnginePage):
|
|
||||||
def javaScriptConsoleMessage(self, level, message, lineNumber, sourceID):
|
|
||||||
logger.info(f"[JS Console] {level.name} at line {lineNumber} in {sourceID}: {message}")
|
|
||||||
|
|
||||||
|
|
||||||
class WebConsole(BECWidget, QWidget):
|
|
||||||
"""
|
|
||||||
A simple widget to display a website
|
|
||||||
"""
|
|
||||||
|
|
||||||
_js_callback = Signal(bool)
|
|
||||||
initialized = Signal()
|
|
||||||
|
|
||||||
PLUGIN = True
|
|
||||||
ICON_NAME = "terminal"
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent=None,
|
|
||||||
config=None,
|
|
||||||
client=None,
|
|
||||||
gui_id=None,
|
|
||||||
startup_cmd: str | None = None,
|
|
||||||
is_bec_shell: bool = False,
|
|
||||||
unique_id: str | None = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
|
||||||
self._mode = ConsoleMode.INACTIVE
|
|
||||||
self._is_bec_shell = is_bec_shell
|
|
||||||
self._startup_cmd = startup_cmd
|
|
||||||
self._is_initialized = False
|
|
||||||
self._unique_id = unique_id
|
|
||||||
self.page = None # Will be set in _set_up_page
|
|
||||||
|
|
||||||
self._startup_timer = QTimer()
|
|
||||||
self._startup_timer.setInterval(500)
|
|
||||||
self._startup_timer.timeout.connect(self._check_page_ready)
|
|
||||||
self._startup_timer.start()
|
|
||||||
self._js_callback.connect(self._on_js_callback)
|
|
||||||
|
|
||||||
self._set_up_page()
|
|
||||||
|
|
||||||
def _set_up_page(self):
|
|
||||||
"""
|
|
||||||
Set up the web page and UI elements.
|
|
||||||
"""
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
self.browser = QWebEngineView(self)
|
|
||||||
|
|
||||||
layout.addWidget(self.browser)
|
|
||||||
self.setLayout(layout)
|
|
||||||
|
|
||||||
# prepare overlay
|
|
||||||
self.overlay = QWidget(self)
|
|
||||||
layout = QVBoxLayout(self.overlay)
|
|
||||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
||||||
label = QLabel("Click to activate terminal", self.overlay)
|
|
||||||
layout.addWidget(label)
|
|
||||||
self.overlay.hide()
|
|
||||||
|
|
||||||
_web_console_registry.register(self)
|
|
||||||
self._token = _web_console_registry._token
|
|
||||||
|
|
||||||
# If no unique_id is provided, create a new page
|
|
||||||
if not self._unique_id:
|
|
||||||
self.page = BECWebEnginePage(self)
|
|
||||||
self.page.authenticationRequired.connect(self._authenticate)
|
|
||||||
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
|
|
||||||
self.browser.setPage(self.page)
|
|
||||||
self._set_mode(ConsoleMode.ACTIVE)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Try to get the page from the registry
|
|
||||||
page_info = _web_console_registry.get_page_info(self._unique_id)
|
|
||||||
if page_info and page_info.page:
|
|
||||||
self.page = page_info.page
|
|
||||||
if not page_info.owner_gui_id or page_info.owner_gui_id == self.gui_id:
|
|
||||||
self.browser.setPage(self.page)
|
|
||||||
# Only set URL if this is a newly created page (no URL set yet)
|
|
||||||
if self.page.url().isEmpty():
|
|
||||||
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
|
|
||||||
else:
|
|
||||||
# We have an existing page, so we don't need the startup timer
|
|
||||||
self._startup_timer.stop()
|
|
||||||
if page_info.owner_gui_id != self.gui_id:
|
|
||||||
self._set_mode(ConsoleMode.INACTIVE)
|
|
||||||
else:
|
|
||||||
self._set_mode(ConsoleMode.ACTIVE)
|
|
||||||
|
|
||||||
def _set_mode(self, mode: ConsoleMode):
|
|
||||||
"""
|
|
||||||
Set the mode of the web console.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
mode (ConsoleMode): The mode to set.
|
|
||||||
"""
|
|
||||||
if not self._unique_id:
|
|
||||||
# For non-unique_id consoles, always active
|
|
||||||
mode = ConsoleMode.ACTIVE
|
|
||||||
|
|
||||||
self._mode = mode
|
|
||||||
match mode:
|
|
||||||
case ConsoleMode.ACTIVE:
|
|
||||||
self.browser.setVisible(True)
|
|
||||||
self.overlay.hide()
|
|
||||||
case ConsoleMode.INACTIVE:
|
|
||||||
self.browser.setVisible(False)
|
|
||||||
self.overlay.show()
|
|
||||||
case ConsoleMode.HIDDEN:
|
|
||||||
self.browser.setVisible(False)
|
|
||||||
self.overlay.hide()
|
|
||||||
|
|
||||||
def _check_page_ready(self):
|
|
||||||
"""
|
|
||||||
Check if the page is ready and stop the timer if it is.
|
|
||||||
"""
|
|
||||||
if not self.page or self.page.isLoading():
|
|
||||||
return
|
|
||||||
|
|
||||||
self.page.runJavaScript("window.term !== undefined", self._js_callback.emit)
|
|
||||||
|
|
||||||
def _on_js_callback(self, ready: bool):
|
|
||||||
"""
|
|
||||||
Callback for when the JavaScript is ready.
|
|
||||||
"""
|
|
||||||
if not ready:
|
|
||||||
return
|
|
||||||
self._is_initialized = True
|
|
||||||
self._startup_timer.stop()
|
|
||||||
if self.startup_cmd:
|
|
||||||
if self._unique_id:
|
|
||||||
page_info = _web_console_registry.get_page_info(self._unique_id)
|
|
||||||
if page_info is None:
|
|
||||||
return
|
|
||||||
if not page_info.initialized:
|
|
||||||
page_info.initialized = True
|
|
||||||
self.write(self.startup_cmd)
|
|
||||||
else:
|
|
||||||
self.write(self.startup_cmd)
|
|
||||||
self.initialized.emit()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def startup_cmd(self):
|
|
||||||
"""
|
|
||||||
Get the startup command for the web console.
|
|
||||||
"""
|
|
||||||
if self._is_bec_shell:
|
|
||||||
if self.bec_dispatcher.cli_server is None:
|
|
||||||
return "bec --nogui"
|
|
||||||
return f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}"
|
|
||||||
return self._startup_cmd
|
|
||||||
|
|
||||||
@startup_cmd.setter
|
|
||||||
def startup_cmd(self, cmd: str):
|
|
||||||
"""
|
|
||||||
Set the startup command for the web console.
|
|
||||||
"""
|
|
||||||
if not isinstance(cmd, str):
|
|
||||||
raise ValueError("Startup command must be a string.")
|
|
||||||
self._startup_cmd = cmd
|
|
||||||
|
|
||||||
def write(self, data: str, send_return: bool = True):
|
|
||||||
"""
|
|
||||||
Send data to the web page
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data (str): The data to send.
|
|
||||||
send_return (bool): Whether to send a return after the data.
|
|
||||||
"""
|
|
||||||
cmd = f"window.term.paste({json.dumps(data)});"
|
|
||||||
if self.page is None:
|
|
||||||
logger.warning("Cannot write to web console: page is not initialized.")
|
|
||||||
return
|
|
||||||
self.page.runJavaScript(cmd)
|
|
||||||
if send_return:
|
|
||||||
self.send_return()
|
|
||||||
|
|
||||||
def take_page_ownership(self, unique_id: str | None = None):
|
|
||||||
"""
|
|
||||||
Take ownership of a web page from the registry. This will transfer the page
|
|
||||||
from its current owner (if any) to this widget.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
unique_id (str): The unique identifier of the page to take ownership of.
|
|
||||||
If None, uses this widget's unique_id.
|
|
||||||
"""
|
|
||||||
if unique_id is None:
|
|
||||||
unique_id = self._unique_id
|
|
||||||
|
|
||||||
if not unique_id:
|
|
||||||
logger.warning("Cannot take page ownership without a unique_id")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get the page from registry
|
|
||||||
page = _web_console_registry.take_page_ownership(unique_id, self.gui_id)
|
|
||||||
|
|
||||||
if not page:
|
|
||||||
logger.warning(f"Page {unique_id} not found in registry")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.page = page
|
|
||||||
self.browser.setPage(page)
|
|
||||||
self._set_mode(ConsoleMode.ACTIVE)
|
|
||||||
logger.info(f"Widget {self.gui_id} took ownership of page {unique_id}")
|
|
||||||
|
|
||||||
def _on_ownership_lost(self):
|
|
||||||
"""
|
|
||||||
Called when this widget loses ownership of its page.
|
|
||||||
Displays the overlay and hides the browser.
|
|
||||||
"""
|
|
||||||
self._set_mode(ConsoleMode.INACTIVE)
|
|
||||||
logger.info(f"Widget {self.gui_id} lost ownership of page {self._unique_id}")
|
|
||||||
|
|
||||||
def yield_ownership(self):
|
|
||||||
"""
|
|
||||||
Yield ownership of the page. The page remains in the registry with no owner,
|
|
||||||
available for another widget to claim. This is automatically called when the
|
|
||||||
widget becomes hidden.
|
|
||||||
"""
|
|
||||||
if not self._unique_id:
|
|
||||||
return
|
|
||||||
success = _web_console_registry.yield_ownership(self.gui_id)
|
|
||||||
if success:
|
|
||||||
self._on_ownership_lost()
|
|
||||||
logger.info(f"Widget {self.gui_id} yielded ownership of page {self._unique_id}")
|
|
||||||
|
|
||||||
def has_ownership(self) -> bool:
|
|
||||||
"""
|
|
||||||
Check if this widget currently has ownership of a page.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if this widget owns a page, False otherwise.
|
|
||||||
"""
|
|
||||||
if not self._unique_id:
|
|
||||||
return False
|
|
||||||
page_info = _web_console_registry.get_page_info(self._unique_id)
|
|
||||||
if page_info is None:
|
|
||||||
return False
|
|
||||||
return page_info.owner_gui_id == self.gui_id
|
|
||||||
|
|
||||||
def hideEvent(self, event):
|
|
||||||
"""
|
|
||||||
Called when the widget is hidden. Automatically yields ownership.
|
|
||||||
"""
|
|
||||||
if self.has_ownership():
|
|
||||||
self.yield_ownership()
|
|
||||||
self._set_mode(ConsoleMode.HIDDEN)
|
|
||||||
super().hideEvent(event)
|
|
||||||
|
|
||||||
def showEvent(self, event):
|
|
||||||
"""
|
|
||||||
Called when the widget is shown. Updates UI state based on ownership.
|
|
||||||
"""
|
|
||||||
super().showEvent(event)
|
|
||||||
if self._unique_id and not self.has_ownership():
|
|
||||||
# Take ownership if the page does not have an owner or
|
|
||||||
# the owner is not visible
|
|
||||||
page_info = _web_console_registry.get_page_info(self._unique_id)
|
|
||||||
if page_info is None:
|
|
||||||
self._set_mode(ConsoleMode.INACTIVE)
|
|
||||||
return
|
|
||||||
if page_info.owner_gui_id is None or not _web_console_registry.owner_is_visible(
|
|
||||||
self._unique_id
|
|
||||||
):
|
|
||||||
self.take_page_ownership(self._unique_id)
|
|
||||||
return
|
|
||||||
if page_info.owner_gui_id != self.gui_id:
|
|
||||||
self._set_mode(ConsoleMode.INACTIVE)
|
|
||||||
return
|
|
||||||
|
|
||||||
def resizeEvent(self, event: QResizeEvent) -> None:
|
|
||||||
super().resizeEvent(event)
|
|
||||||
self.overlay.resize(event.size())
|
|
||||||
|
|
||||||
def mousePressEvent(self, event: QMouseEvent) -> None:
|
|
||||||
if event.button() == Qt.MouseButton.LeftButton and not self.has_ownership():
|
|
||||||
self.take_page_ownership(self._unique_id)
|
|
||||||
event.accept()
|
|
||||||
return
|
|
||||||
return super().mousePressEvent(event)
|
|
||||||
|
|
||||||
def _authenticate(self, _, auth):
|
|
||||||
"""
|
|
||||||
Authenticate the request with the provided username and password.
|
|
||||||
"""
|
|
||||||
auth.setUser("user")
|
|
||||||
auth.setPassword(self._token)
|
|
||||||
|
|
||||||
def send_return(self):
|
|
||||||
"""
|
|
||||||
Send return to the web page
|
|
||||||
"""
|
|
||||||
self.page.runJavaScript(
|
|
||||||
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 13}))"
|
|
||||||
)
|
|
||||||
|
|
||||||
def send_ctrl_c(self):
|
|
||||||
"""
|
|
||||||
Send Ctrl+C to the web page
|
|
||||||
"""
|
|
||||||
self.page.runJavaScript(
|
|
||||||
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 3}))"
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_readonly(self, readonly: bool):
|
|
||||||
"""
|
|
||||||
Set the web console to read-only mode.
|
|
||||||
"""
|
|
||||||
if not isinstance(readonly, bool):
|
|
||||||
raise ValueError("Readonly must be a boolean.")
|
|
||||||
self.setEnabled(not readonly)
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
"""
|
|
||||||
Clean up the registry by removing any instances that are no longer valid.
|
|
||||||
"""
|
|
||||||
self._startup_timer.stop()
|
|
||||||
_web_console_registry.unregister(self)
|
|
||||||
super().cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
class BECShell(WebConsole):
|
|
||||||
"""
|
|
||||||
A WebConsole pre-configured to run the BEC shell.
|
|
||||||
We cannot simply expose the web console properties to Qt as we need to have a deterministic
|
|
||||||
startup behavior for sharing the same shell instance across multiple widgets.
|
|
||||||
"""
|
|
||||||
|
|
||||||
ICON_NAME = "hub"
|
|
||||||
|
|
||||||
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
|
||||||
super().__init__(
|
|
||||||
parent=parent,
|
|
||||||
config=config,
|
|
||||||
client=client,
|
|
||||||
gui_id=gui_id,
|
|
||||||
is_bec_shell=True,
|
|
||||||
unique_id="bec_shell",
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
|
||||||
widget = QTabWidget()
|
|
||||||
|
|
||||||
# Create two consoles with different unique_ids
|
|
||||||
web_console1 = WebConsole(startup_cmd="bec --nogui", unique_id="console1")
|
|
||||||
web_console2 = WebConsole(startup_cmd="htop")
|
|
||||||
web_console3 = WebConsole(startup_cmd="bec --nogui", unique_id="console1")
|
|
||||||
widget.addTab(web_console1, "Console 1")
|
|
||||||
widget.addTab(web_console2, "Console 2")
|
|
||||||
widget.addTab(web_console3, "Console 3 -- mirror of Console 1")
|
|
||||||
widget.show()
|
|
||||||
|
|
||||||
# Demonstrate page sharing:
|
|
||||||
# After initialization, web_console2 can take ownership of console1's page:
|
|
||||||
# web_console2.take_page_ownership("console1")
|
|
||||||
|
|
||||||
widget.resize(800, 600)
|
|
||||||
|
|
||||||
def _close_cons1():
|
|
||||||
web_console2.close()
|
|
||||||
web_console2.deleteLater()
|
|
||||||
|
|
||||||
# QTimer.singleShot(3000, _close_cons1)
|
|
||||||
|
|
||||||
sys.exit(app.exec_())
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{'files': ['web_console.py']}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from pyside6_qtermwidget import QTermWidget # pylint: disable=ungrouped-imports
|
||||||
|
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
widget = QTermWidget()
|
||||||
|
|
||||||
|
widget.show()
|
||||||
|
sys.exit(app.exec())
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class BecTerminal(Protocol):
|
||||||
|
"""Implementors of this protocol must also be subclasses of QWidget"""
|
||||||
|
|
||||||
|
def write(self, text: str, add_newline: bool = True): ...
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""A wrapper for the optional external dependency pyside6_qtermwidget.
|
||||||
|
Simply displays a message in a QLabel if the dependency is not installed."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from qtpy.QtCore import QIODevice, QPoint, QSize, QUrl, Signal # type: ignore
|
||||||
|
from qtpy.QtGui import QAction, QFont, QKeyEvent, QResizeEvent, Qt # type: ignore
|
||||||
|
from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pyside6_qtermwidget import QTermWidget
|
||||||
|
except ImportError:
|
||||||
|
QTermWidget = None
|
||||||
|
|
||||||
|
|
||||||
|
def _forward(func):
|
||||||
|
"""Apply to a private method to forward the call to the method on QTermWidget with the same name,
|
||||||
|
(with leading '_' removed) if it is defined, otherwise do nothing."""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(self, *args, **kwargs):
|
||||||
|
target = getattr(self, "_main_widget")
|
||||||
|
if QTermWidget:
|
||||||
|
method = getattr(target, func.__name__[1:])
|
||||||
|
return method(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
...
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
class BecQTerm(QWidget):
|
||||||
|
activity = Signal()
|
||||||
|
bell = Signal(str)
|
||||||
|
copy_available = Signal(bool)
|
||||||
|
current_directory_changed = Signal(str)
|
||||||
|
finished = Signal()
|
||||||
|
profile_changed = Signal(str)
|
||||||
|
received_data = Signal(str)
|
||||||
|
silence = Signal()
|
||||||
|
term_got_focus = Signal()
|
||||||
|
term_key_pressed = Signal(QKeyEvent)
|
||||||
|
term_lost_focus = Signal()
|
||||||
|
title_changed = Signal()
|
||||||
|
url_activated = Signal(QUrl, bool)
|
||||||
|
|
||||||
|
def __init__(self, /, parent: QWidget | None = None, **kwargs) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._layout = QVBoxLayout()
|
||||||
|
self.setLayout(self._layout)
|
||||||
|
if QTermWidget:
|
||||||
|
self._main_widget = QTermWidget(parent=self)
|
||||||
|
self._main_widget.activity.connect(self.activity)
|
||||||
|
self._main_widget.bell.connect(self.bell)
|
||||||
|
self._main_widget.copyAvailable.connect(self.copy_available)
|
||||||
|
self._main_widget.currentDirectoryChanged.connect(self.current_directory_changed)
|
||||||
|
self._main_widget.finished.connect(self.finished)
|
||||||
|
self._main_widget.profileChanged.connect(self.profile_changed)
|
||||||
|
self._main_widget.receivedData.connect(self.received_data)
|
||||||
|
self._main_widget.silence.connect(self.silence)
|
||||||
|
self._main_widget.termGetFocus.connect(self.term_got_focus)
|
||||||
|
self._main_widget.termKeyPressed.connect(self.term_key_pressed)
|
||||||
|
self._main_widget.termLostFocus.connect(self.term_lost_focus)
|
||||||
|
self._main_widget.titleChanged.connect(self.title_changed)
|
||||||
|
self._main_widget.urlActivated.connect(self.url_activated)
|
||||||
|
self._setEnvironment([f"{k}={v}" for k, v in os.environ.items()])
|
||||||
|
self._setColorScheme("Solarized")
|
||||||
|
else:
|
||||||
|
self._layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self._main_widget = QLabel("pyside6_qterminal is not installed!")
|
||||||
|
|
||||||
|
self._layout.addWidget(self._main_widget)
|
||||||
|
|
||||||
|
def write(self, text: str, add_newline: bool = True):
|
||||||
|
if add_newline:
|
||||||
|
text += "\n"
|
||||||
|
self._sendText(text)
|
||||||
|
|
||||||
|
# automatically forwarded to the widget only if it exists
|
||||||
|
@_forward
|
||||||
|
def _addCustomColorSchemeDir(self, custom_dir: str, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _autoHideMouseAfter(self, delay: int, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _availableColorSchemes(self) -> list[str]: ...
|
||||||
|
@_forward
|
||||||
|
def _availableKeyBindings(self) -> list[str]: ...
|
||||||
|
@_forward
|
||||||
|
def _bracketText(self, text: str, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _bracketedPasteModeIsDisabled(self, /) -> bool: ...
|
||||||
|
@_forward
|
||||||
|
def _changeDir(self, dir: str, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _clear(self, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _clearCustomKeyBindingsDir(self, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _copyClipboard(self, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _disableBracketedPasteMode(self, disable: bool, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _filterActions(self, position: QPoint, /) -> list[QAction]: ...
|
||||||
|
@_forward
|
||||||
|
def _flowControlEnabled(self, /) -> bool: ...
|
||||||
|
@_forward
|
||||||
|
def _getAvailableColorSchemes(self, /) -> list[str]: ...
|
||||||
|
@_forward
|
||||||
|
def _getForegroundProcessId(self, /) -> int: ...
|
||||||
|
@_forward
|
||||||
|
def _getMargin(self, /) -> int: ...
|
||||||
|
@_forward
|
||||||
|
def _getPtySlaveFd(self, /) -> int: ...
|
||||||
|
@_forward
|
||||||
|
def _getSelectionEnd(self, row: int, column: int, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _getSelectionStart(self, row: int, column: int, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _getShellPID(self, /) -> int: ...
|
||||||
|
@_forward
|
||||||
|
def _getTerminalFont(self, /) -> QFont: ...
|
||||||
|
@_forward
|
||||||
|
def _historyLinesCount(self, /) -> int: ...
|
||||||
|
@_forward
|
||||||
|
def _historySize(self, /) -> int: ...
|
||||||
|
@_forward
|
||||||
|
def _icon(self, /) -> str: ...
|
||||||
|
@_forward
|
||||||
|
def _isBidiEnabled(self, /) -> bool: ...
|
||||||
|
@_forward
|
||||||
|
def _isTitleChanged(self, /) -> bool: ...
|
||||||
|
@_forward
|
||||||
|
def _keyBindings(self, /) -> str: ...
|
||||||
|
@_forward
|
||||||
|
def _pasteClipboard(self, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _pasteSelection(self, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _resizeEvent(self, arg__1: QResizeEvent, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _saveHistory(self, device: QIODevice, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _screenColumnsCount(self, /) -> int: ...
|
||||||
|
@_forward
|
||||||
|
def _screenLinesCount(self, /) -> int: ...
|
||||||
|
@_forward
|
||||||
|
def _scrollToEnd(self, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _selectedText(self, /, preserveLineBreaks: bool = ...) -> str: ...
|
||||||
|
@_forward
|
||||||
|
def _selectionChanged(self, textSelected: bool, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _sendKeyEvent(self, e: QKeyEvent, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _sendText(self, text: str, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _sessionFinished(self, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setArgs(self, args: Sequence[str], /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setAutoClose(self, arg__1: bool, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setBidiEnabled(self, enabled: bool, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setBlinkingCursor(self, blink: bool, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setBoldIntense(self, boldIntense: bool, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setColorScheme(self, name: str, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setConfirmMultilinePaste(self, confirmMultilinePaste: bool, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setCustomKeyBindingsDir(self, custom_dir: str, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setDrawLineChars(self, drawLineChars: bool, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setEnvironment(self, environment: Sequence[str], /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setFlowControlEnabled(self, enabled: bool, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setFlowControlWarningEnabled(self, enabled: bool, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setHistorySize(self, lines: int, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setKeyBindings(self, kb: str, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setMargin(self, arg__1: int, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setMonitorActivity(self, arg__1: bool, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setMonitorSilence(self, arg__1: bool, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setMotionAfterPasting(self, arg__1: int, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setSelectionEnd(self, row: int, column: int, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setSelectionStart(self, row: int, column: int, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setShellProgram(self, program: str, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setSilenceTimeout(self, seconds: int, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setSize(self, arg__1: QSize, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setTerminalBackgroundImage(self, backgroundImage: str, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setTerminalBackgroundMode(self, mode: int, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setTerminalFont(self, font: QFont | str | Sequence[str], /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setTerminalOpacity(self, level: float, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setTerminalSizeHint(self, enabled: bool, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setTrimPastedTrailingNewlines(self, trimPastedTrailingNewlines: bool, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setWordCharacters(self, chars: str, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _setWorkingDirectory(self, dir: str, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _sizeHint(self, /) -> QSize: ...
|
||||||
|
@_forward
|
||||||
|
def _startShellProgram(self, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _startTerminalTeletype(self, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _terminalSizeHint(self, /) -> bool: ...
|
||||||
|
@_forward
|
||||||
|
def _title(self, /) -> str: ...
|
||||||
|
@_forward
|
||||||
|
def _toggleShowSearchBar(self, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _wordCharacters(self, /) -> str: ...
|
||||||
|
@_forward
|
||||||
|
def _workingDirectory(self, /) -> str: ...
|
||||||
|
@_forward
|
||||||
|
def _zoomIn(self, /) -> None: ...
|
||||||
|
@_forward
|
||||||
|
def _zoomOut(self, /) -> None: ...
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from bec_widgets.widgets.utility.bec_term.protocol import BecTerminal
|
||||||
|
from bec_widgets.widgets.utility.bec_term.qtermwidget_wrapper import BecQTerm
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_bec_term_class() -> type[BecTerminal]:
|
||||||
|
return BecQTerm
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
"""Utilities for filtering and formatting in the LogPanel"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from collections import deque
|
|
||||||
from typing import Callable, Iterator
|
|
||||||
|
|
||||||
from bec_lib.logger import LogLevel
|
|
||||||
from bec_lib.messages import LogMessage
|
|
||||||
from qtpy.QtCore import QDateTime
|
|
||||||
|
|
||||||
LinesHtmlFormatter = Callable[[deque[LogMessage]], Iterator[str]]
|
|
||||||
LineFormatter = Callable[[LogMessage], str]
|
|
||||||
LineFilter = Callable[[LogMessage], bool] | None
|
|
||||||
|
|
||||||
ANSI_ESCAPE_REGEX = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
||||||
|
|
||||||
|
|
||||||
def replace_escapes(s: str):
|
|
||||||
s = ANSI_ESCAPE_REGEX.sub("", s)
|
|
||||||
return s.replace(" ", " ").replace("\n", "<br />").replace("\t", " ")
|
|
||||||
|
|
||||||
|
|
||||||
def level_filter(msg: LogMessage, thresh: int):
|
|
||||||
return LogLevel[msg.content["log_type"].upper()].value >= thresh
|
|
||||||
|
|
||||||
|
|
||||||
def noop_format(line: LogMessage):
|
|
||||||
_textline = line.log_msg if isinstance(line.log_msg, str) else line.log_msg["text"]
|
|
||||||
return replace_escapes(_textline.strip()) + "<br />"
|
|
||||||
|
|
||||||
|
|
||||||
def simple_color_format(line: LogMessage, colors: dict[LogLevel, str]):
|
|
||||||
color = colors.get(LogLevel[line.content["log_type"].upper()]) or colors[LogLevel.INFO]
|
|
||||||
return f'<font color="{color}">{noop_format(line)}</font>'
|
|
||||||
|
|
||||||
|
|
||||||
def create_formatter(line_format: LineFormatter, line_filter: LineFilter) -> LinesHtmlFormatter:
|
|
||||||
def _formatter(data: deque[LogMessage]):
|
|
||||||
if line_filter is not None:
|
|
||||||
return (line_format(line) for line in data if line_filter(line))
|
|
||||||
else:
|
|
||||||
return (line_format(line) for line in data)
|
|
||||||
|
|
||||||
return _formatter
|
|
||||||
|
|
||||||
|
|
||||||
def log_txt(line):
|
|
||||||
return line.log_msg if isinstance(line.log_msg, str) else line.log_msg["text"]
|
|
||||||
|
|
||||||
|
|
||||||
def log_time(line):
|
|
||||||
return QDateTime.fromMSecsSinceEpoch(int(line.log_msg["record"]["time"]["timestamp"] * 1000))
|
|
||||||
|
|
||||||
|
|
||||||
def log_svc(line):
|
|
||||||
return line.log_msg["service_name"]
|
|
||||||
@@ -30,7 +30,7 @@ class LogPanelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
|||||||
return DOM_XML
|
return DOM_XML
|
||||||
|
|
||||||
def group(self):
|
def group(self):
|
||||||
return "BEC Services"
|
return ""
|
||||||
|
|
||||||
def icon(self):
|
def icon(self):
|
||||||
return designer_material_icon(LogPanel.ICON_NAME)
|
return designer_material_icon(LogPanel.ICON_NAME)
|
||||||
@@ -51,7 +51,7 @@ class LogPanelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
|||||||
return "LogPanel"
|
return "LogPanel"
|
||||||
|
|
||||||
def toolTip(self):
|
def toolTip(self):
|
||||||
return "Displays a log panel"
|
return "LogPanel"
|
||||||
|
|
||||||
def whatsThis(self):
|
def whatsThis(self):
|
||||||
return self.toolTip()
|
return self.toolTip()
|
||||||
|
|||||||
@@ -2,21 +2,31 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import operator
|
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from functools import partial, reduce
|
from dataclasses import dataclass
|
||||||
from re import Pattern
|
from functools import partial
|
||||||
from typing import TYPE_CHECKING, Literal
|
from typing import Iterable, Literal
|
||||||
|
|
||||||
from bec_lib.client import BECClient
|
from bec_lib.client import BECClient
|
||||||
from bec_lib.endpoints import MessageEndpoints
|
from bec_lib.endpoints import MessageEndpoints
|
||||||
from bec_lib.logger import LogLevel, bec_logger
|
from bec_lib.logger import LogLevel, bec_logger
|
||||||
from bec_lib.messages import LogMessage, StatusMessage
|
from bec_lib.messages import LogMessage, StatusMessage
|
||||||
from pyqtgraph import SignalProxy
|
from bec_qthemes import material_icon
|
||||||
from qtpy.QtCore import QDateTime, QObject, Qt, Signal
|
from qtpy.QtCore import Signal # type: ignore
|
||||||
from qtpy.QtGui import QFont
|
from qtpy.QtCore import (
|
||||||
|
QAbstractTableModel,
|
||||||
|
QCoreApplication,
|
||||||
|
QDateTime,
|
||||||
|
QModelIndex,
|
||||||
|
QObject,
|
||||||
|
QPersistentModelIndex,
|
||||||
|
QSize,
|
||||||
|
QSortFilterProxyModel,
|
||||||
|
Qt,
|
||||||
|
QTimer,
|
||||||
|
)
|
||||||
|
from qtpy.QtGui import QColor
|
||||||
from qtpy.QtWidgets import (
|
from qtpy.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
@@ -25,204 +35,414 @@ from qtpy.QtWidgets import (
|
|||||||
QDialog,
|
QDialog,
|
||||||
QGridLayout,
|
QGridLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
|
QHeaderView,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QScrollArea,
|
QSizePolicy,
|
||||||
QTextEdit,
|
QTableView,
|
||||||
|
QToolButton,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
from thefuzz import fuzz
|
||||||
|
|
||||||
from bec_widgets.utils.bec_connector import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
from bec_widgets.utils.colors import apply_theme, get_theme_palette
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
|
from bec_widgets.utils.colors import apply_theme
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.widgets.editors.text_box.text_box import TextBox
|
|
||||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECServiceStatusMixin
|
|
||||||
from bec_widgets.widgets.utility.logpanel._util import (
|
|
||||||
LineFilter,
|
|
||||||
LineFormatter,
|
|
||||||
LinesHtmlFormatter,
|
|
||||||
create_formatter,
|
|
||||||
level_filter,
|
|
||||||
log_svc,
|
|
||||||
log_time,
|
|
||||||
log_txt,
|
|
||||||
noop_format,
|
|
||||||
simple_color_format,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
|
||||||
from qtpy.QtCore import SignalInstance
|
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
# TODO: improve log color handling
|
_DEFAULT_LOG_COLORS = {
|
||||||
DEFAULT_LOG_COLORS = {
|
LogLevel.INFO.name: QColor("#FFFFFF"),
|
||||||
LogLevel.INFO: "#FFFFFF",
|
LogLevel.SUCCESS.name: QColor("#00FF00"),
|
||||||
LogLevel.SUCCESS: "#00FF00",
|
LogLevel.WARNING.name: QColor("#FFCC00"),
|
||||||
LogLevel.WARNING: "#FFCC00",
|
LogLevel.ERROR.name: QColor("#FF0000"),
|
||||||
LogLevel.ERROR: "#FF0000",
|
LogLevel.DEBUG.name: QColor("#0000CC"),
|
||||||
LogLevel.DEBUG: "#0000CC",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Constants:
|
||||||
|
FUZZ_THRESHOLD = 80
|
||||||
|
UPDATE_INTERVAL_MS = 200
|
||||||
|
headers = ["level", "timestamp", "service_name", "message", "function"]
|
||||||
|
|
||||||
|
|
||||||
|
_CONST = _Constants()
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampUpdate:
|
||||||
|
def __init__(self, value: QDateTime | None, update_type: Literal["start", "end"]) -> None:
|
||||||
|
self.value = value
|
||||||
|
self.update_type = update_type
|
||||||
|
|
||||||
|
|
||||||
class BecLogsQueue(BECConnector, QObject):
|
class BecLogsQueue(BECConnector, QObject):
|
||||||
"""Manages getting logs from BEC Redis and formatting them for display"""
|
"""Manages getting logs from BEC Redis and formatting them for display"""
|
||||||
|
|
||||||
RPC = False
|
RPC = False
|
||||||
new_message = Signal()
|
new_messages = Signal()
|
||||||
|
paused = Signal(bool)
|
||||||
|
_instance: BecLogsQueue | None = None
|
||||||
|
|
||||||
def __init__(
|
@classmethod
|
||||||
self,
|
def instance(cls):
|
||||||
parent: QObject | None,
|
if cls._instance is None:
|
||||||
maxlen: int = 1000,
|
cls._instance = cls(QCoreApplication.instance())
|
||||||
line_formatter: LineFormatter = noop_format,
|
return cls._instance
|
||||||
**kwargs,
|
|
||||||
) -> None:
|
def __init__(self, parent: QObject | None, maxlen: int = 2500, **kwargs) -> None:
|
||||||
|
if BecLogsQueue._instance:
|
||||||
|
raise RuntimeError("Create no more than one BecLogsQueue - use BecLogsQueue.instance()")
|
||||||
super().__init__(parent=parent, **kwargs)
|
super().__init__(parent=parent, **kwargs)
|
||||||
self._timestamp_start: QDateTime | None = None
|
|
||||||
self._timestamp_end: QDateTime | None = None
|
|
||||||
self._max_length = maxlen
|
self._max_length = maxlen
|
||||||
self._data: deque[LogMessage] = deque([], self._max_length)
|
self._paused = False
|
||||||
self._display_queue: deque[str] = deque([], self._max_length)
|
self._data = deque(
|
||||||
self._log_level: str | None = None
|
(
|
||||||
self._search_query: Pattern | str | None = None
|
item["data"]
|
||||||
self._selected_services: set[str] | None = None
|
for item in self.bec_dispatcher.client.connector.xread(
|
||||||
self._set_formatter_and_update_filter(line_formatter)
|
MessageEndpoints.log(), count=self._max_length, id="0"
|
||||||
# instance attribute still accessible after c++ object is deleted, so the callback can be unregistered
|
)
|
||||||
|
),
|
||||||
|
maxlen=self._max_length,
|
||||||
|
)
|
||||||
|
self._incoming: deque[LogMessage] = deque([], maxlen=self._max_length)
|
||||||
self.bec_dispatcher.connect_slot(self._process_incoming_log_msg, MessageEndpoints.log())
|
self.bec_dispatcher.connect_slot(self._process_incoming_log_msg, MessageEndpoints.log())
|
||||||
|
|
||||||
|
self._update_timer = QTimer(self, interval=_CONST.UPDATE_INTERVAL_MS)
|
||||||
|
self._update_timer.timeout.connect(self._proc_update)
|
||||||
|
QCoreApplication.instance().aboutToQuit.connect(self.cleanup) # type: ignore
|
||||||
|
self._update_timer.start()
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self._data)
|
||||||
|
|
||||||
|
@SafeSlot()
|
||||||
|
def toggle_pause(self):
|
||||||
|
self._paused = not self._paused
|
||||||
|
self.paused.emit(self._paused)
|
||||||
|
|
||||||
|
def row_data(self, index: int) -> LogMessage | None:
|
||||||
|
if index < 0 or index > (len(self._data) - 1):
|
||||||
|
return None
|
||||||
|
return self._data[index]
|
||||||
|
|
||||||
|
def cell_data(self, row: int, key: str):
|
||||||
|
if key == "level":
|
||||||
|
return self._data[row].log_type.upper()
|
||||||
|
|
||||||
|
msg_item = self._data[row].log_msg
|
||||||
|
if isinstance(msg_item, str):
|
||||||
|
return msg_item
|
||||||
|
if key == "service_name":
|
||||||
|
return msg_item.get(key)
|
||||||
|
elif key in ["service_name", "function", "message"]:
|
||||||
|
return msg_item.get("record", {}).get(key)
|
||||||
|
elif key == "timestamp":
|
||||||
|
return msg_item.get("record", {}).get("time", {}).get("repr")
|
||||||
|
|
||||||
|
def log_timestamp(self, row: int) -> float:
|
||||||
|
msg_item = self._data[row].log_msg
|
||||||
|
if isinstance(msg_item, str):
|
||||||
|
return 0
|
||||||
|
return msg_item.get("record", {}).get("time", {}).get("timestamp")
|
||||||
|
|
||||||
def cleanup(self, *_):
|
def cleanup(self, *_):
|
||||||
"""Stop listening to the Redis log stream"""
|
"""Stop listening to the Redis log stream"""
|
||||||
self.bec_dispatcher.disconnect_slot(
|
self.bec_dispatcher.disconnect_slot(
|
||||||
self._process_incoming_log_msg, [MessageEndpoints.log()]
|
self._process_incoming_log_msg, [MessageEndpoints.log()]
|
||||||
)
|
)
|
||||||
|
self._update_timer.stop()
|
||||||
|
BecLogsQueue._instance = None
|
||||||
|
|
||||||
@SafeSlot(verify_sender=True)
|
@SafeSlot(verify_sender=True)
|
||||||
def _process_incoming_log_msg(self, msg: dict, _metadata: dict):
|
def _process_incoming_log_msg(self, msg: dict, _metadata: dict):
|
||||||
try:
|
try:
|
||||||
_msg = LogMessage(**msg)
|
_msg = LogMessage(**msg)
|
||||||
self._data.append(_msg)
|
self._incoming.append(_msg)
|
||||||
if self.filter is None or self.filter(_msg):
|
|
||||||
self._display_queue.append(self._line_formatter(_msg))
|
|
||||||
self.new_message.emit()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if "Internal C++ object (BecLogsQueue) already deleted." in e.args:
|
if "Internal C++ object (BecLogsQueue) already deleted." in e.args:
|
||||||
return
|
return
|
||||||
logger.warning(f"Error in LogPanel incoming message callback: {e}")
|
logger.warning(f"Error in LogPanel incoming message callback: {e}")
|
||||||
|
|
||||||
def _set_formatter_and_update_filter(self, line_formatter: LineFormatter = noop_format):
|
@SafeSlot(verify_sender=True)
|
||||||
self._line_formatter: LineFormatter = line_formatter
|
def _proc_update(self):
|
||||||
self._queue_formatter: LinesHtmlFormatter = create_formatter(
|
if self._paused or len(self._incoming) == 0:
|
||||||
self._line_formatter, self.filter
|
|
||||||
)
|
|
||||||
|
|
||||||
def _combine_filters(self, *args: LineFilter):
|
|
||||||
return lambda msg: reduce(operator.and_, [filt(msg) for filt in args if filt is not None])
|
|
||||||
|
|
||||||
def _create_re_filter(self) -> LineFilter:
|
|
||||||
if self._search_query is None:
|
|
||||||
return None
|
|
||||||
elif isinstance(self._search_query, str):
|
|
||||||
return lambda line: self._search_query in log_txt(line)
|
|
||||||
return lambda line: self._search_query.match(log_txt(line)) is not None
|
|
||||||
|
|
||||||
def _create_service_filter(self):
|
|
||||||
return (
|
|
||||||
lambda line: self._selected_services is None or log_svc(line) in self._selected_services
|
|
||||||
)
|
|
||||||
|
|
||||||
def _create_timestamp_filter(self) -> LineFilter:
|
|
||||||
s, e = self._timestamp_start, self._timestamp_end
|
|
||||||
if s is e is None:
|
|
||||||
return lambda msg: True
|
|
||||||
|
|
||||||
def _time_filter(msg):
|
|
||||||
msg_time = log_time(msg)
|
|
||||||
if s is None:
|
|
||||||
return msg_time <= e
|
|
||||||
if e is None:
|
|
||||||
return s <= msg_time
|
|
||||||
return s <= msg_time <= e
|
|
||||||
|
|
||||||
return _time_filter
|
|
||||||
|
|
||||||
@property
|
|
||||||
def filter(self) -> LineFilter:
|
|
||||||
"""A function which filters a log message based on all applied criteria"""
|
|
||||||
thresh = LogLevel[self._log_level].value if self._log_level is not None else 0
|
|
||||||
return self._combine_filters(
|
|
||||||
partial(level_filter, thresh=thresh),
|
|
||||||
self._create_re_filter(),
|
|
||||||
self._create_timestamp_filter(),
|
|
||||||
self._create_service_filter(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def update_level_filter(self, level: str):
|
|
||||||
"""Change the log-level of the level filter"""
|
|
||||||
if level not in [l.name for l in LogLevel]:
|
|
||||||
logger.error(f"Logging level {level} unrecognized for filter!")
|
|
||||||
return
|
return
|
||||||
self._log_level = level
|
self._data.extend(self._incoming)
|
||||||
self._set_formatter_and_update_filter(self._line_formatter)
|
self._incoming.clear()
|
||||||
|
self.new_messages.emit()
|
||||||
|
|
||||||
def update_search_filter(self, search_query: Pattern | str | None = None):
|
|
||||||
"""Change the string or regex to filter against"""
|
|
||||||
self._search_query = search_query
|
|
||||||
self._set_formatter_and_update_filter(self._line_formatter)
|
|
||||||
|
|
||||||
def update_time_filter(self, start: QDateTime | None, end: QDateTime | None):
|
class BecLogsTableModel(QAbstractTableModel):
|
||||||
"""Change the start and/or end times to filter against"""
|
def __init__(self, parent: QWidget | None = None):
|
||||||
self._timestamp_start = start
|
super().__init__(parent)
|
||||||
self._timestamp_end = end
|
self.log_queue = BecLogsQueue.instance()
|
||||||
self._set_formatter_and_update_filter(self._line_formatter)
|
self.log_queue.new_messages.connect(self.handle_new_messages)
|
||||||
|
self._headers = _CONST.headers
|
||||||
|
|
||||||
def update_service_filter(self, services: set[str]):
|
def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int:
|
||||||
"""Change the selected services to display"""
|
return len(self.log_queue)
|
||||||
self._selected_services = services
|
|
||||||
self._set_formatter_and_update_filter(self._line_formatter)
|
|
||||||
|
|
||||||
def update_line_formatter(self, line_formatter: LineFormatter):
|
def columnCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int:
|
||||||
"""Update the formatter"""
|
return len(self._headers)
|
||||||
self._set_formatter_and_update_filter(line_formatter)
|
|
||||||
|
|
||||||
def display_all(self) -> str:
|
def headerData(self, section, orientation, role=int(Qt.ItemDataRole.DisplayRole)):
|
||||||
"""Return formatted output for all log messages"""
|
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
|
||||||
return "\n".join(self._queue_formatter(self._data.copy()))
|
return self._headers[section]
|
||||||
|
return None
|
||||||
|
|
||||||
def format_new(self):
|
def get_row_data(self, index: QModelIndex) -> LogMessage | None:
|
||||||
"""Return formatted output for the display queue"""
|
"""Return the row data for the given index."""
|
||||||
res = "\n".join(self._display_queue)
|
if not index.isValid():
|
||||||
self._display_queue = deque([], self._max_length)
|
return None
|
||||||
return res
|
return self.log_queue.row_data(index.row())
|
||||||
|
|
||||||
def clear_logs(self):
|
def timestamp(self, row: int):
|
||||||
"""Clear the cache and display queue"""
|
return QDateTime.fromMSecsSinceEpoch(int(self.log_queue.log_timestamp(row) * 1000))
|
||||||
self._data = deque([])
|
|
||||||
self._display_queue = deque([])
|
|
||||||
|
|
||||||
def fetch_history(self):
|
def data(self, index, role=int(Qt.ItemDataRole.DisplayRole)):
|
||||||
"""Fetch all available messages from Redis"""
|
"""Return data for the given index and role."""
|
||||||
self._data = deque(
|
if not index.isValid():
|
||||||
item["data"]
|
return
|
||||||
for item in self.bec_dispatcher.client.connector.xread(
|
if role in [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole]:
|
||||||
MessageEndpoints.log().endpoint, from_start=True, count=self._max_length
|
return self.log_queue.cell_data(index.row(), self._headers[index.column()])
|
||||||
)
|
if role in [Qt.ItemDataRole.ForegroundRole]:
|
||||||
|
return self._map_log_level_color(self.log_queue.cell_data(index.row(), "level"))
|
||||||
|
|
||||||
|
def _map_log_level_color(self, data):
|
||||||
|
return _DEFAULT_LOG_COLORS.get(data)
|
||||||
|
|
||||||
|
def handle_new_messages(self):
|
||||||
|
self.dataChanged.emit(
|
||||||
|
self.index(0, 0), self.index(self.rowCount() - 1, self.columnCount() - 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
def unique_service_names_from_history(self) -> set[str]:
|
|
||||||
"""Go through the log history to determine active service names"""
|
class LogMsgProxyModel(QSortFilterProxyModel):
|
||||||
return set(msg.log_msg["service_name"] for msg in self._data)
|
show_service_column = Signal(bool)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent=None,
|
||||||
|
service_filter: set[str] | None = None,
|
||||||
|
level_filter: LogLevel | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._service_filter = service_filter or set()
|
||||||
|
self._level_filter: LogLevel | None = level_filter
|
||||||
|
self._filter_text: str = ""
|
||||||
|
self._fuzzy_search: bool = False
|
||||||
|
self._time_filter_start: QDateTime | None = None
|
||||||
|
self._time_filter_end: QDateTime | None = None
|
||||||
|
|
||||||
|
def get_row_data(self, rows: Iterable[QModelIndex]) -> Iterable[LogMessage | None]:
|
||||||
|
return (self.sourceModel().get_row_data(self.mapToSource(idx)) for idx in rows)
|
||||||
|
|
||||||
|
def sourceModel(self) -> BecLogsTableModel:
|
||||||
|
return super().sourceModel() # type: ignore
|
||||||
|
|
||||||
|
@SafeSlot(int, int)
|
||||||
|
def refresh(self, *_):
|
||||||
|
self.invalidateRowsFilter()
|
||||||
|
|
||||||
|
@SafeSlot(None)
|
||||||
|
@SafeSlot(set)
|
||||||
|
def update_service_filter(self, filter: set[str]):
|
||||||
|
"""Filter to the selected services (show any service in the provided set)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filter (set[str] | None): set of services for which to show logs"""
|
||||||
|
self._service_filter = filter
|
||||||
|
self.show_service_column.emit(len(filter) != 1)
|
||||||
|
self.invalidateRowsFilter()
|
||||||
|
|
||||||
|
@SafeSlot(None)
|
||||||
|
@SafeSlot(LogLevel)
|
||||||
|
def update_level_filter(self, filter: LogLevel | None):
|
||||||
|
"""Filter to the selected log level
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filter (str | None): lowest log level to show"""
|
||||||
|
self._level_filter = filter
|
||||||
|
self.invalidateRowsFilter()
|
||||||
|
|
||||||
|
@SafeSlot(str)
|
||||||
|
def update_filter_text(self, filter: str):
|
||||||
|
"""Filter messages based on text
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filter (str | None): set of services for which to show logs"""
|
||||||
|
self._filter_text = filter
|
||||||
|
self.invalidateRowsFilter()
|
||||||
|
|
||||||
|
@SafeSlot(bool)
|
||||||
|
def update_fuzzy(self, state: bool):
|
||||||
|
"""Set text filter to fuzzy search or not
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state (bool): fuzzy search on"""
|
||||||
|
self._fuzzy_search = state
|
||||||
|
self.invalidateRowsFilter()
|
||||||
|
|
||||||
|
@SafeSlot(TimestampUpdate)
|
||||||
|
def update_timestamp(self, update: TimestampUpdate):
|
||||||
|
if update.update_type == "start":
|
||||||
|
self._time_filter_start = update.value
|
||||||
|
else:
|
||||||
|
self._time_filter_end = update.value
|
||||||
|
self.invalidateRowsFilter()
|
||||||
|
|
||||||
|
def filterAcceptsRow(self, source_row: int, source_parent) -> bool:
|
||||||
|
# No service filter, and no filter text, display everything
|
||||||
|
possible_filters = [
|
||||||
|
self._service_filter,
|
||||||
|
self._level_filter,
|
||||||
|
self._filter_text,
|
||||||
|
self._time_filter_start,
|
||||||
|
self._time_filter_end,
|
||||||
|
]
|
||||||
|
if not any(map(bool, possible_filters)):
|
||||||
|
return True
|
||||||
|
model = self.sourceModel()
|
||||||
|
# Filter out services
|
||||||
|
if self._service_filter:
|
||||||
|
col = _CONST.headers.index("service_name")
|
||||||
|
if model.data(model.index(source_row, col, source_parent)) not in self._service_filter:
|
||||||
|
return False
|
||||||
|
# Filter out levels
|
||||||
|
if self._level_filter:
|
||||||
|
col = _CONST.headers.index("level")
|
||||||
|
level: str = model.data(model.index(source_row, col, source_parent)) # type: ignore
|
||||||
|
if LogLevel[level] < self._level_filter:
|
||||||
|
return False
|
||||||
|
# Filter time
|
||||||
|
if self._time_filter_start:
|
||||||
|
if model.timestamp(source_row) < self._time_filter_start:
|
||||||
|
return False
|
||||||
|
if self._time_filter_end:
|
||||||
|
if model.timestamp(source_row) > self._time_filter_end:
|
||||||
|
return False
|
||||||
|
# Filter message text - must go last because this can return True
|
||||||
|
if self._filter_text:
|
||||||
|
col = _CONST.headers.index("message")
|
||||||
|
msg: str = model.data(model.index(source_row, col, source_parent)).lower() # type: ignore
|
||||||
|
if self._fuzzy_search:
|
||||||
|
return fuzz.partial_ratio(self._filter_text.lower(), msg) >= _CONST.FUZZ_THRESHOLD
|
||||||
|
else:
|
||||||
|
return self._filter_text.lower() in msg.lower()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class BecLogTableView(QTableView):
|
||||||
|
def __init__(self, *args, max_message_width: int = 1000, **kwargs) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
header = QHeaderView(Qt.Orientation.Horizontal, parent=self)
|
||||||
|
header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
||||||
|
header.setStretchLastSection(True)
|
||||||
|
header.setMaximumSectionSize(max_message_width)
|
||||||
|
self.setHorizontalHeader(header)
|
||||||
|
|
||||||
|
def model(self) -> LogMsgProxyModel:
|
||||||
|
return super().model() # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
class LogPanel(BECWidget, QWidget):
|
||||||
|
"""Live display of the BEC logs in a table view."""
|
||||||
|
|
||||||
|
PLUGIN = True
|
||||||
|
ICON_NAME = "browse_activity"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: QWidget | None = None,
|
||||||
|
max_message_width: int = 1000,
|
||||||
|
show_toolbar: bool = True,
|
||||||
|
service_filter: set[str] | None = None,
|
||||||
|
level_filter: LogLevel | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent=parent, **kwargs)
|
||||||
|
self._setup_models(service_filter=service_filter, level_filter=level_filter)
|
||||||
|
self._layout = QVBoxLayout()
|
||||||
|
self.setLayout(self._layout)
|
||||||
|
if show_toolbar:
|
||||||
|
self._setup_toolbar(client=self.client)
|
||||||
|
self._setup_table_view(max_message_width=max_message_width)
|
||||||
|
self._update_service_filter(service_filter or set())
|
||||||
|
if show_toolbar:
|
||||||
|
self._connect_toolbar()
|
||||||
|
self._proxy.show_service_column.connect(self._show_service_column)
|
||||||
|
colors = QApplication.instance().theme.accent_colors # type: ignore
|
||||||
|
dict_colors = QApplication.instance().theme.colors # type: ignore
|
||||||
|
_DEFAULT_LOG_COLORS.update(
|
||||||
|
{
|
||||||
|
LogLevel.INFO.name: dict_colors["FG"],
|
||||||
|
LogLevel.SUCCESS.name: colors.success,
|
||||||
|
LogLevel.WARNING.name: colors.warning,
|
||||||
|
LogLevel.ERROR.name: colors.emergency,
|
||||||
|
LogLevel.DEBUG.name: dict_colors["BORDER"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._table.scrollToBottom()
|
||||||
|
|
||||||
|
def _setup_models(self, service_filter: set[str] | None, level_filter: LogLevel | None):
|
||||||
|
self._model = BecLogsTableModel(parent=self)
|
||||||
|
self._proxy = LogMsgProxyModel(
|
||||||
|
parent=self, service_filter=service_filter, level_filter=level_filter
|
||||||
|
)
|
||||||
|
self._proxy.setSourceModel(self._model)
|
||||||
|
self._model.log_queue.new_messages.connect(self._proxy.refresh)
|
||||||
|
|
||||||
|
def _setup_table_view(self, max_message_width: int) -> None:
|
||||||
|
"""Setup the table view."""
|
||||||
|
self._table = BecLogTableView(self, max_message_width=max_message_width)
|
||||||
|
self._table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
self._layout.addWidget(self._table)
|
||||||
|
self._table.setModel(self._proxy)
|
||||||
|
self._table.setHorizontalScrollMode(QTableView.ScrollMode.ScrollPerPixel)
|
||||||
|
self._table.setTextElideMode(Qt.TextElideMode.ElideRight)
|
||||||
|
self._table.resizeColumnsToContents()
|
||||||
|
|
||||||
|
def _setup_toolbar(self, client: BECClient):
|
||||||
|
self._toolbar = LogPanelToolbar(self, client)
|
||||||
|
self._layout.addWidget(self._toolbar)
|
||||||
|
|
||||||
|
def _connect_toolbar(self):
|
||||||
|
self._toolbar.services_selected.connect(self._proxy.update_service_filter)
|
||||||
|
self._toolbar.search_textbox.textChanged.connect(self._proxy.update_filter_text)
|
||||||
|
self._toolbar.level_changed.connect(self._proxy.update_level_filter)
|
||||||
|
self._toolbar.fuzzy_changed.connect(self._proxy.update_fuzzy)
|
||||||
|
self._toolbar.timestamp_update.connect(self._proxy.update_timestamp)
|
||||||
|
self._toolbar.pause_button.clicked.connect(self._model.log_queue.toggle_pause)
|
||||||
|
self._model.log_queue.paused.connect(self._toolbar._update_pause_button_icon)
|
||||||
|
|
||||||
|
def _update_service_filter(self, filter: set[str]):
|
||||||
|
self._service_filter = filter
|
||||||
|
self._proxy.update_service_filter(filter)
|
||||||
|
self._table.setColumnHidden(
|
||||||
|
_CONST.headers.index("service_name"), len(self._service_filter) == 1
|
||||||
|
)
|
||||||
|
|
||||||
|
@SafeSlot(bool)
|
||||||
|
def _show_service_column(self, show: bool):
|
||||||
|
self._table.setColumnHidden(_CONST.headers.index("service_name"), not show)
|
||||||
|
|
||||||
|
def sizeHint(self) -> QSize:
|
||||||
|
return QSize(600, 300)
|
||||||
|
|
||||||
|
|
||||||
class LogPanelToolbar(QWidget):
|
class LogPanelToolbar(QWidget):
|
||||||
|
services_selected = Signal(set)
|
||||||
|
level_changed = Signal(LogLevel)
|
||||||
|
fuzzy_changed = Signal(bool)
|
||||||
|
timestamp_update = Signal(TimestampUpdate)
|
||||||
|
|
||||||
services_selected: SignalInstance = Signal(set)
|
def __init__(self, parent: QWidget | None = None, client: BECClient | None = None) -> None:
|
||||||
|
|
||||||
def __init__(self, parent: QWidget | None = None) -> None:
|
|
||||||
"""A toolbar for the logpanel, mainly used for managing the states of filters"""
|
"""A toolbar for the logpanel, mainly used for managing the states of filters"""
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
||||||
@@ -231,51 +451,69 @@ class LogPanelToolbar(QWidget):
|
|||||||
self._timestamp_end: QDateTime | None = None
|
self._timestamp_end: QDateTime | None = None
|
||||||
|
|
||||||
self._unique_service_names: set[str] = set()
|
self._unique_service_names: set[str] = set()
|
||||||
self._services_selected: set[str] | None = None
|
self._services_selected: set[str] = set()
|
||||||
|
|
||||||
self.layout = QHBoxLayout(self) # type: ignore
|
self._layout = QHBoxLayout(self)
|
||||||
|
|
||||||
self.service_choice_button = QPushButton("Select services", self)
|
if client is not None:
|
||||||
self.layout.addWidget(self.service_choice_button)
|
self.client = client
|
||||||
self.service_choice_button.clicked.connect(self._open_service_filter_dialog)
|
self.service_choice_button = QPushButton("Select services", self)
|
||||||
|
self._layout.addWidget(self.service_choice_button)
|
||||||
|
self.service_choice_button.clicked.connect(self._open_service_filter_dialog)
|
||||||
|
self.service_list_update(self.client.service_status)
|
||||||
|
self._services_selected = self._unique_service_names
|
||||||
|
|
||||||
self.filter_level_dropdown = self._log_level_box()
|
self.filter_level_dropdown = self._log_level_box()
|
||||||
self.layout.addWidget(self.filter_level_dropdown)
|
self._layout.addWidget(self.filter_level_dropdown)
|
||||||
|
self.filter_level_dropdown.currentTextChanged.connect(self._emit_level)
|
||||||
self.clear_button = QPushButton("Clear all", self)
|
|
||||||
self.layout.addWidget(self.clear_button)
|
|
||||||
self.fetch_button = QPushButton("Fetch history", self)
|
|
||||||
self.layout.addWidget(self.fetch_button)
|
|
||||||
|
|
||||||
self._string_search_box()
|
self._string_search_box()
|
||||||
|
|
||||||
self.timerange_button = QPushButton("Set time range", self)
|
self.timerange_button = QPushButton("Set time range", self)
|
||||||
self.layout.addWidget(self.timerange_button)
|
self._layout.addWidget(self.timerange_button)
|
||||||
|
self.timerange_button.clicked.connect(self._open_datetime_dialog)
|
||||||
|
|
||||||
@property
|
self.pause_button = QToolButton()
|
||||||
def time_start(self):
|
self.pause_button.setIcon(material_icon("pause", size=(20, 20), convert_to_pixmap=False))
|
||||||
return self._timestamp_start
|
self._PLAYING_TOOLTIP = "Pause live log updates."
|
||||||
|
self._PAUSED_TOOLTIP = "Continue live log updates."
|
||||||
|
self.pause_button.setToolTip(self._PLAYING_TOOLTIP)
|
||||||
|
self._layout.addWidget(self.pause_button)
|
||||||
|
|
||||||
@property
|
@SafeSlot(bool)
|
||||||
def time_end(self):
|
def _update_pause_button_icon(self, paused):
|
||||||
return self._timestamp_end
|
if paused:
|
||||||
|
icon = "play_arrow"
|
||||||
|
tooltip = self._PAUSED_TOOLTIP
|
||||||
|
else:
|
||||||
|
icon = "pause"
|
||||||
|
tooltip = self._PLAYING_TOOLTIP
|
||||||
|
self.pause_button.setIcon(material_icon(icon, size=(20, 20), convert_to_pixmap=False))
|
||||||
|
self.pause_button.setToolTip(tooltip)
|
||||||
|
|
||||||
def _string_search_box(self):
|
def _string_search_box(self):
|
||||||
self.layout.addWidget(QLabel("Search: "))
|
self._layout.addWidget(QLabel("Search: "))
|
||||||
self.search_textbox = QLineEdit()
|
self.search_textbox = QLineEdit()
|
||||||
self.layout.addWidget(self.search_textbox)
|
self._layout.addWidget(self.search_textbox)
|
||||||
self.layout.addWidget(QLabel("Use regex: "))
|
self._layout.addWidget(QLabel("Fuzzy: "))
|
||||||
self.regex_enabled = QCheckBox()
|
self.fuzzy = QCheckBox()
|
||||||
self.layout.addWidget(self.regex_enabled)
|
self._layout.addWidget(self.fuzzy)
|
||||||
self.update_re_button = QPushButton("Update search", self)
|
self.fuzzy.checkStateChanged.connect(self._emit_fuzzy)
|
||||||
self.layout.addWidget(self.update_re_button)
|
|
||||||
|
|
||||||
def _log_level_box(self):
|
def _log_level_box(self):
|
||||||
box = QComboBox()
|
box = QComboBox()
|
||||||
box.setToolTip("Display logs with equal or greater significance to the selected level.")
|
box.setToolTip("Display logs with equal or greater significance to the selected level.")
|
||||||
[box.addItem(l.name) for l in LogLevel]
|
[box.addItem(level.name) for level in LogLevel]
|
||||||
return box
|
return box
|
||||||
|
|
||||||
|
@SafeSlot(str)
|
||||||
|
def _emit_level(self, level: str):
|
||||||
|
self.level_changed.emit(LogLevel[level])
|
||||||
|
|
||||||
|
@SafeSlot(Qt.CheckState)
|
||||||
|
def _emit_fuzzy(self, state: Qt.CheckState):
|
||||||
|
self.fuzzy_changed.emit(state == Qt.CheckState.Checked)
|
||||||
|
|
||||||
def _current_ts(self, selection_type: Literal["start", "end"]):
|
def _current_ts(self, selection_type: Literal["start", "end"]):
|
||||||
if selection_type == "start":
|
if selection_type == "start":
|
||||||
return self._timestamp_start
|
return self._timestamp_start
|
||||||
@@ -284,6 +522,7 @@ class LogPanelToolbar(QWidget):
|
|||||||
else:
|
else:
|
||||||
raise ValueError(f"timestamps can only be for the start or end, not {selection_type}")
|
raise ValueError(f"timestamps can only be for the start or end, not {selection_type}")
|
||||||
|
|
||||||
|
@SafeSlot()
|
||||||
def _open_datetime_dialog(self):
|
def _open_datetime_dialog(self):
|
||||||
"""Open dialog window for timestamp filter selection"""
|
"""Open dialog window for timestamp filter selection"""
|
||||||
self._dt_dialog = QDialog(self)
|
self._dt_dialog = QDialog(self)
|
||||||
@@ -312,8 +551,8 @@ class LogPanelToolbar(QWidget):
|
|||||||
)
|
)
|
||||||
_layout.addWidget(date_clear_button)
|
_layout.addWidget(date_clear_button)
|
||||||
|
|
||||||
for v in [("start", label_start), ("end", label_end)]:
|
date_button_set("start", label_start)
|
||||||
date_button_set(*v)
|
date_button_set("end", label_end)
|
||||||
|
|
||||||
close_button = QPushButton("Close", parent=self._dt_dialog)
|
close_button = QPushButton("Close", parent=self._dt_dialog)
|
||||||
close_button.clicked.connect(self._dt_dialog.accept)
|
close_button.clicked.connect(self._dt_dialog.accept)
|
||||||
@@ -352,27 +591,23 @@ class LogPanelToolbar(QWidget):
|
|||||||
self._timestamp_start = dt
|
self._timestamp_start = dt
|
||||||
else:
|
else:
|
||||||
self._timestamp_end = dt
|
self._timestamp_end = dt
|
||||||
|
self.timestamp_update.emit(TimestampUpdate(value=dt, update_type=selection_type))
|
||||||
|
|
||||||
@SafeSlot(dict, set)
|
def service_list_update(self, services_info: dict[str, StatusMessage]):
|
||||||
def service_list_update(
|
|
||||||
self, services_info: dict[str, StatusMessage], services_from_history: set[str], *_, **__
|
|
||||||
):
|
|
||||||
"""Change the list of services which can be selected"""
|
"""Change the list of services which can be selected"""
|
||||||
self._unique_service_names = set([s.split("/")[0] for s in services_info.keys()])
|
self._unique_service_names = set([s.split("/")[0] for s in services_info.keys()])
|
||||||
self._unique_service_names |= services_from_history
|
|
||||||
if self._services_selected is None:
|
|
||||||
self._services_selected = self._unique_service_names
|
|
||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
def _open_service_filter_dialog(self):
|
def _open_service_filter_dialog(self):
|
||||||
|
self.service_list_update(self.client.service_status)
|
||||||
if len(self._unique_service_names) == 0 or self._services_selected is None:
|
if len(self._unique_service_names) == 0 or self._services_selected is None:
|
||||||
return
|
return
|
||||||
self._svc_dialog = QDialog(self)
|
self._svc_dialog = QDialog(self)
|
||||||
self._svc_dialog.setWindowTitle(f"Select services to show logs from")
|
self._svc_dialog.setWindowTitle("Select services to show logs from")
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
self._svc_dialog.setLayout(layout)
|
self._svc_dialog.setLayout(layout)
|
||||||
|
|
||||||
service_cb_grid = QGridLayout(parent=self._svc_dialog)
|
service_cb_grid = QGridLayout()
|
||||||
layout.addLayout(service_cb_grid)
|
layout.addLayout(service_cb_grid)
|
||||||
|
|
||||||
def check_box(name: str, checked: Qt.CheckState):
|
def check_box(name: str, checked: Qt.CheckState):
|
||||||
@@ -398,146 +633,6 @@ class LogPanelToolbar(QWidget):
|
|||||||
self._svc_dialog.deleteLater()
|
self._svc_dialog.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
class LogPanel(TextBox):
|
|
||||||
"""Displays a log panel"""
|
|
||||||
|
|
||||||
ICON_NAME = "terminal"
|
|
||||||
service_list_update = Signal(dict, set)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent=None,
|
|
||||||
client: BECClient | None = None,
|
|
||||||
service_status: BECServiceStatusMixin | None = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
"""Initialize the LogPanel widget."""
|
|
||||||
super().__init__(parent=parent, client=client, config={"text": ""}, **kwargs)
|
|
||||||
self._update_colors()
|
|
||||||
self._service_status = service_status or BECServiceStatusMixin(self, client=self.client) # type: ignore
|
|
||||||
self._log_manager = BecLogsQueue(
|
|
||||||
parent=self, line_formatter=partial(simple_color_format, colors=self._colors)
|
|
||||||
)
|
|
||||||
self._proxy_update = SignalProxy(
|
|
||||||
self._log_manager.new_message, rateLimit=1, slot=self._on_append
|
|
||||||
)
|
|
||||||
|
|
||||||
self.toolbar = LogPanelToolbar(parent=self)
|
|
||||||
self.toolbar_area = QScrollArea()
|
|
||||||
self.toolbar_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
||||||
self.toolbar_area.setSizeAdjustPolicy(QScrollArea.SizeAdjustPolicy.AdjustToContents)
|
|
||||||
self.toolbar_area.setFixedHeight(int(self.toolbar.clear_button.height() * 2))
|
|
||||||
self.toolbar_area.setWidget(self.toolbar)
|
|
||||||
|
|
||||||
self.layout.addWidget(self.toolbar_area)
|
|
||||||
self.toolbar.clear_button.clicked.connect(self._on_clear)
|
|
||||||
self.toolbar.fetch_button.clicked.connect(self._on_fetch)
|
|
||||||
self.toolbar.update_re_button.clicked.connect(self._on_re_update)
|
|
||||||
self.toolbar.search_textbox.returnPressed.connect(self._on_re_update)
|
|
||||||
self.toolbar.regex_enabled.checkStateChanged.connect(self._on_re_update)
|
|
||||||
self.toolbar.filter_level_dropdown.currentTextChanged.connect(self._set_level_filter)
|
|
||||||
|
|
||||||
self.toolbar.timerange_button.clicked.connect(self._choose_datetime)
|
|
||||||
self._service_status.services_update.connect(self._update_service_list)
|
|
||||||
self.service_list_update.connect(self.toolbar.service_list_update)
|
|
||||||
self.toolbar.services_selected.connect(self._update_service_filter)
|
|
||||||
|
|
||||||
self.text_box_text_edit.setFont(QFont("monospace", 12))
|
|
||||||
self.text_box_text_edit.setHtml("")
|
|
||||||
self.text_box_text_edit.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
|
|
||||||
|
|
||||||
self._connect_to_theme_change()
|
|
||||||
|
|
||||||
@SafeSlot(set)
|
|
||||||
def _update_service_filter(self, services: set[str]):
|
|
||||||
self._log_manager.update_service_filter(services)
|
|
||||||
self._on_redraw()
|
|
||||||
|
|
||||||
@SafeSlot(dict, dict)
|
|
||||||
def _update_service_list(self, services_info: dict[str, StatusMessage], *_, **__):
|
|
||||||
self.service_list_update.emit(
|
|
||||||
services_info, self._log_manager.unique_service_names_from_history()
|
|
||||||
)
|
|
||||||
|
|
||||||
@SafeSlot()
|
|
||||||
def _choose_datetime(self):
|
|
||||||
self.toolbar._open_datetime_dialog()
|
|
||||||
self._set_time_filter()
|
|
||||||
|
|
||||||
def _connect_to_theme_change(self):
|
|
||||||
"""Connect to the theme change signal."""
|
|
||||||
qapp = QApplication.instance()
|
|
||||||
if hasattr(qapp, "theme_signal"):
|
|
||||||
qapp.theme_signal.theme_updated.connect(self._on_redraw) # type: ignore
|
|
||||||
|
|
||||||
def _update_colors(self):
|
|
||||||
self._colors = DEFAULT_LOG_COLORS.copy()
|
|
||||||
self._colors.update({LogLevel.INFO: get_theme_palette().text().color().name()})
|
|
||||||
|
|
||||||
def _cursor_to_end(self):
|
|
||||||
c = self.text_box_text_edit.textCursor()
|
|
||||||
c.movePosition(c.MoveOperation.End)
|
|
||||||
self.text_box_text_edit.setTextCursor(c)
|
|
||||||
|
|
||||||
@SafeSlot()
|
|
||||||
@SafeSlot(str)
|
|
||||||
def _on_redraw(self, *_):
|
|
||||||
self._update_colors()
|
|
||||||
self._log_manager.update_line_formatter(partial(simple_color_format, colors=self._colors))
|
|
||||||
self.set_html_text(self._log_manager.display_all())
|
|
||||||
self._cursor_to_end()
|
|
||||||
|
|
||||||
@SafeSlot(verify_sender=True)
|
|
||||||
def _on_append(self, *_):
|
|
||||||
self.text_box_text_edit.insertHtml(self._log_manager.format_new())
|
|
||||||
self._cursor_to_end()
|
|
||||||
|
|
||||||
@SafeSlot()
|
|
||||||
def _on_clear(self):
|
|
||||||
self._log_manager.clear_logs()
|
|
||||||
self.set_html_text(self._log_manager.display_all())
|
|
||||||
self._cursor_to_end()
|
|
||||||
|
|
||||||
@SafeSlot()
|
|
||||||
@SafeSlot(Qt.CheckState)
|
|
||||||
def _on_re_update(self, *_):
|
|
||||||
if self.toolbar.regex_enabled.isChecked():
|
|
||||||
try:
|
|
||||||
search_query = re.compile(self.toolbar.search_textbox.text())
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to compile search regex with error {e}")
|
|
||||||
search_query = None
|
|
||||||
logger.info(f"Setting LogPanel search regex to {search_query}")
|
|
||||||
else:
|
|
||||||
search_query = self.toolbar.search_textbox.text()
|
|
||||||
logger.info(f'Setting LogPanel search string to "{search_query}"')
|
|
||||||
self._log_manager.update_search_filter(search_query)
|
|
||||||
self.set_html_text(self._log_manager.display_all())
|
|
||||||
self._cursor_to_end()
|
|
||||||
|
|
||||||
@SafeSlot()
|
|
||||||
def _on_fetch(self):
|
|
||||||
self._log_manager.fetch_history()
|
|
||||||
self.set_html_text(self._log_manager.display_all())
|
|
||||||
self._cursor_to_end()
|
|
||||||
|
|
||||||
@SafeSlot(str)
|
|
||||||
def _set_level_filter(self, level: str):
|
|
||||||
self._log_manager.update_level_filter(level)
|
|
||||||
self._on_redraw()
|
|
||||||
|
|
||||||
@SafeSlot()
|
|
||||||
def _set_time_filter(self):
|
|
||||||
self._log_manager.update_time_filter(self.toolbar.time_start, self.toolbar.time_end)
|
|
||||||
self._on_redraw()
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
self._service_status.cleanup()
|
|
||||||
self._log_manager.cleanup()
|
|
||||||
self._log_manager.deleteLater()
|
|
||||||
super().cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -545,7 +640,15 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
apply_theme("dark")
|
apply_theme("dark")
|
||||||
widget = LogPanel()
|
panel = QWidget()
|
||||||
|
queue = BecLogsQueue(panel)
|
||||||
|
layout = QVBoxLayout(panel)
|
||||||
|
layout.addWidget(QLabel("All logs, no filters:"))
|
||||||
|
layout.addWidget(LogPanel())
|
||||||
|
layout.addWidget(QLabel("All services, level filter WARNING preapplied:"))
|
||||||
|
layout.addWidget(LogPanel(level_filter=LogLevel.WARNING))
|
||||||
|
layout.addWidget(QLabel('All services, service filter {"DeviceServer"} preapplied:'))
|
||||||
|
layout.addWidget(LogPanel(service_filter={"DeviceServer"}))
|
||||||
|
|
||||||
widget.show()
|
panel.show()
|
||||||
sys.exit(app.exec())
|
sys.exit(app.exec())
|
||||||
|
|||||||
+80
-72
@@ -1,54 +1,34 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["hatchling"]
|
|
||||||
build-backend = "hatchling.build"
|
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "bec_widgets"
|
name = "bec_widgets"
|
||||||
version = "3.4.3"
|
version = "3.6.0"
|
||||||
description = "BEC Widgets"
|
description = "BEC Widgets"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 3 - Alpha",
|
"Development Status :: 3 - Alpha",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Topic :: Scientific/Engineering",
|
"Topic :: Scientific/Engineering",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console
|
"PyJWT~=2.9",
|
||||||
"bec_lib~=3.107,>=3.107.2",
|
"PySide6==6.9.0",
|
||||||
"bec_qthemes~=1.0, >=1.3.4",
|
"PySide6-QtAds==4.4.0",
|
||||||
"black>=26,<27", # needed for bw-generate-cli
|
"bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console
|
||||||
"isort>=5.13, <9.0", # needed for bw-generate-cli
|
"bec_lib~=3.107,>=3.107.2",
|
||||||
"ophyd_devices~=1.29, >=1.29.1",
|
"bec_qthemes~=1.0, >=1.3.4",
|
||||||
"pydantic~=2.0",
|
"black>=26,<27", # needed for bw-generate-cli
|
||||||
"pyqtgraph==0.13.7",
|
"copier~=9.7",
|
||||||
"PySide6==6.9.0",
|
"darkdetect~=0.8",
|
||||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
"isort>=5.13, <9.0", # needed for bw-generate-cli
|
||||||
"qtpy~=2.4",
|
"markdown~=3.9",
|
||||||
"thefuzz~=0.22",
|
"ophyd_devices~=1.29, >=1.29.1",
|
||||||
"qtmonaco~=0.8, >=0.8.1",
|
"pydantic~=2.0",
|
||||||
"darkdetect~=0.8",
|
"pylsp-bec~=1.2",
|
||||||
"PySide6-QtAds==4.4.0",
|
"pyqtgraph==0.13.7",
|
||||||
"pylsp-bec~=1.2",
|
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||||
"copier~=9.7",
|
"qtmonaco~=0.8, >=0.8.1",
|
||||||
"typer~=0.15",
|
"qtpy~=2.4",
|
||||||
"markdown~=3.9",
|
"thefuzz~=0.22",
|
||||||
"PyJWT~=2.9",
|
"typer~=0.15",
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
"coverage~=7.0",
|
|
||||||
"fakeredis~=2.23, >=2.23.2",
|
|
||||||
"pytest-bec-e2e>=2.21.4, <=4.0",
|
|
||||||
"pytest-qt~=4.4",
|
|
||||||
"pytest-random-order~=1.1",
|
|
||||||
"pytest-timeout~=2.2",
|
|
||||||
"pytest-xvfb~=3.0",
|
|
||||||
"pytest~=8.0",
|
|
||||||
"pytest-cov~=6.1.1",
|
|
||||||
"watchdog~=6.0",
|
|
||||||
"pre_commit~=4.2",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -56,10 +36,47 @@ dev = [
|
|||||||
Homepage = "https://gitlab.psi.ch/bec/bec_widgets"
|
Homepage = "https://gitlab.psi.ch/bec/bec_widgets"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
bw-generate-cli = "bec_widgets.cli.generate_cli:main"
|
|
||||||
bec-gui-server = "bec_widgets.cli.server:main"
|
|
||||||
bec-designer = "bec_widgets.utils.bec_designer:main"
|
|
||||||
bec-app = "bec_widgets.applications.main_app:main"
|
bec-app = "bec_widgets.applications.main_app:main"
|
||||||
|
bec-designer = "bec_widgets.utils.bec_designer:main"
|
||||||
|
bec-gui-server = "bec_widgets.cli.server:main"
|
||||||
|
bw-generate-cli = "bec_widgets.cli.generate_cli:main"
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"coverage~=7.0",
|
||||||
|
"fakeredis~=2.23, >=2.23.2",
|
||||||
|
"pytest-bec-e2e>=2.21.4, <=4.0",
|
||||||
|
"pytest-qt~=4.4",
|
||||||
|
"pytest-random-order~=1.1",
|
||||||
|
"pytest-timeout~=2.2",
|
||||||
|
"pytest-xvfb~=3.0",
|
||||||
|
"pytest~=8.0",
|
||||||
|
"pytest-cov~=6.1.1",
|
||||||
|
"watchdog~=6.0",
|
||||||
|
"pre_commit~=4.2",
|
||||||
|
]
|
||||||
|
qtermwidget = [
|
||||||
|
"pyside6_qtermwidget",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 100
|
||||||
|
skip-magic-trailing-comma = true
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
skip_empty = true # exclude empty *files*, e.g. __init__.py, from the report
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
"return NotImplemented",
|
||||||
|
"raise NotImplementedError",
|
||||||
|
"\\.\\.\\.",
|
||||||
|
'if __name__ == "__main__":',
|
||||||
|
]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
include = ["*"]
|
include = ["*"]
|
||||||
@@ -69,10 +86,6 @@ exclude = ["docs/**", "tests/**"]
|
|||||||
include = ["*"]
|
include = ["*"]
|
||||||
exclude = ["docs/**", "tests/**"]
|
exclude = ["docs/**", "tests/**"]
|
||||||
|
|
||||||
[tool.black]
|
|
||||||
line-length = 100
|
|
||||||
skip-magic-trailing-comma = true
|
|
||||||
|
|
||||||
[tool.isort]
|
[tool.isort]
|
||||||
profile = "black"
|
profile = "black"
|
||||||
line_length = 100
|
line_length = 100
|
||||||
@@ -80,6 +93,12 @@ multi_line_output = 3
|
|||||||
include_trailing_comma = true
|
include_trailing_comma = true
|
||||||
known_first_party = ["bec_widgets"]
|
known_first_party = ["bec_widgets"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
skip-magic-trailing-comma = true
|
||||||
|
|
||||||
[tool.semantic_release]
|
[tool.semantic_release]
|
||||||
build_command = "pip install build wheel && python -m build"
|
build_command = "pip install build wheel && python -m build"
|
||||||
version_toml = ["pyproject.toml:project.version"]
|
version_toml = ["pyproject.toml:project.version"]
|
||||||
@@ -90,16 +109,16 @@ default = "semantic-release <semantic-release>"
|
|||||||
|
|
||||||
[tool.semantic_release.commit_parser_options]
|
[tool.semantic_release.commit_parser_options]
|
||||||
allowed_tags = [
|
allowed_tags = [
|
||||||
"build",
|
"build",
|
||||||
"chore",
|
"chore",
|
||||||
"ci",
|
"ci",
|
||||||
"docs",
|
"docs",
|
||||||
"feat",
|
"feat",
|
||||||
"fix",
|
"fix",
|
||||||
"perf",
|
"perf",
|
||||||
"style",
|
"style",
|
||||||
"refactor",
|
"refactor",
|
||||||
"test",
|
"test",
|
||||||
]
|
]
|
||||||
minor_tags = ["feat"]
|
minor_tags = ["feat"]
|
||||||
patch_tags = ["fix", "perf"]
|
patch_tags = ["fix", "perf"]
|
||||||
@@ -116,14 +135,3 @@ env = "GH_TOKEN"
|
|||||||
[tool.semantic_release.publish]
|
[tool.semantic_release.publish]
|
||||||
dist_glob_patterns = ["dist/*"]
|
dist_glob_patterns = ["dist/*"]
|
||||||
upload_to_vcs_release = true
|
upload_to_vcs_release = true
|
||||||
|
|
||||||
[tool.coverage.report]
|
|
||||||
skip_empty = true # exclude empty *files*, e.g. __init__.py, from the report
|
|
||||||
exclude_lines = [
|
|
||||||
"pragma: no cover",
|
|
||||||
"if TYPE_CHECKING:",
|
|
||||||
"return NotImplemented",
|
|
||||||
"raise NotImplementedError",
|
|
||||||
"\\.\\.\\.",
|
|
||||||
'if __name__ == "__main__":',
|
|
||||||
]
|
|
||||||
|
|||||||
+18
-5
@@ -1,3 +1,5 @@
|
|||||||
|
import traceback
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import qtpy.QtCore
|
import qtpy.QtCore
|
||||||
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
|
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
|
||||||
@@ -5,12 +7,14 @@ from qtpy.QtCore import QTimer
|
|||||||
|
|
||||||
|
|
||||||
class TestableQTimer(QTimer):
|
class TestableQTimer(QTimer):
|
||||||
_instances: list[tuple[QTimer, str]] = []
|
_instances: list[tuple[QTimer, str, str]] = []
|
||||||
_current_test_name: str = ""
|
_current_test_name: str = ""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
TestableQTimer._instances.append((self, TestableQTimer._current_test_name))
|
tb = traceback.format_stack()
|
||||||
|
init_line = list(filter(lambda msg: "QTimer(" in msg, tb))[-1]
|
||||||
|
TestableQTimer._instances.append((self, TestableQTimer._current_test_name, init_line))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def check_all_stopped(cls, qtbot):
|
def check_all_stopped(cls, qtbot):
|
||||||
@@ -20,12 +24,21 @@ class TestableQTimer(QTimer):
|
|||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
return "already deleted" in e.args[0]
|
return "already deleted" in e.args[0]
|
||||||
|
|
||||||
|
def _format_timers(timers: list[tuple[QTimer, str, str]]):
|
||||||
|
return "\n".join(
|
||||||
|
f"Timer: {t[0]}\n in test: {t[1]}\n created at:{t[2]}" for t in timers
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
qtbot.waitUntil(lambda: all(_is_done_or_deleted(timer) for timer, _ in cls._instances))
|
qtbot.waitUntil(
|
||||||
|
lambda: all(_is_done_or_deleted(timer) for timer, _, _ in cls._instances)
|
||||||
|
)
|
||||||
except QtBotTimeoutError as exc:
|
except QtBotTimeoutError as exc:
|
||||||
active_timers = list(filter(lambda t: t[0].isActive(), cls._instances))
|
active_timers = list(filter(lambda t: t[0].isActive(), cls._instances))
|
||||||
(t.stop() for t, _ in cls._instances)
|
(t.stop() for t, _, _ in cls._instances)
|
||||||
raise TimeoutError(f"Failed to stop all timers: {active_timers}") from exc
|
raise TimeoutError(
|
||||||
|
f"Failed to stop all timers:\n{_format_timers(active_timers)}"
|
||||||
|
) from exc
|
||||||
cls._instances = []
|
cls._instances = []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ def threads_check_fixture(threads_check):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def gui_id():
|
def gui_id():
|
||||||
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturb"""
|
"""New gui id each time, to ensure no 'gui is alive' zombie key can perturb"""
|
||||||
return f"figure_{random.randint(0,100)}" # make a new gui id each time, to ensure no 'gui is alive' zombie key can perturb
|
return f"figure_{random.randint(0, 100)}" # make a new gui id each time, to ensure no 'gui is alive' zombie key can perturb
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
@@ -51,6 +51,7 @@ def connected_client_gui_obj(qtbot, gui_id, bec_client_lib):
|
|||||||
qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000)
|
qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000)
|
||||||
yield gui
|
yield gui
|
||||||
finally:
|
finally:
|
||||||
gui.bec.delete_all() # ensure clean state
|
if (bec := getattr(gui, "bec", None)) is not None:
|
||||||
qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000)
|
bec.delete_all() # ensure clean state
|
||||||
|
qtbot.waitUntil(lambda: len(bec.widget_list()) == 0, timeout=10000)
|
||||||
gui.kill_server()
|
gui.kill_server()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from bec_widgets.cli.client import Image, MotorMap, Waveform
|
from bec_widgets.cli.client import Image, MotorMap, Waveform
|
||||||
|
from bec_widgets.cli.client_utils import BECGuiClient
|
||||||
from bec_widgets.cli.rpc.rpc_base import RPCReference
|
from bec_widgets.cli.rpc.rpc_base import RPCReference
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
@@ -122,7 +123,7 @@ def test_ring_bar(qtbot, connected_client_gui_obj):
|
|||||||
assert gui._ipython_registry[bar._gui_id].__class__.__name__ == "RingProgressBar"
|
assert gui._ipython_registry[bar._gui_id].__class__.__name__ == "RingProgressBar"
|
||||||
|
|
||||||
|
|
||||||
def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
|
def test_rpc_gui_obj(connected_client_gui_obj: BECGuiClient, qtbot):
|
||||||
gui = connected_client_gui_obj
|
gui = connected_client_gui_obj
|
||||||
|
|
||||||
qtbot.waitUntil(lambda: len(gui.windows) == 1, timeout=3000)
|
qtbot.waitUntil(lambda: len(gui.windows) == 1, timeout=3000)
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
|
|||||||
if object_name == "BECShell":
|
if object_name == "BECShell":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip WebConsole as ttyd is not installed
|
# Skip BecConsole as ttyd is not installed
|
||||||
if object_name == "WebConsole":
|
if object_name == "BecConsole":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
#############################
|
#############################
|
||||||
|
|||||||
@@ -260,22 +260,6 @@ def test_widgets_e2e_image(qtbot, connected_client_gui_obj, random_generator_fro
|
|||||||
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
||||||
|
|
||||||
|
|
||||||
# TODO re-enable when issue is resolved #560
|
|
||||||
# @pytest.mark.timeout(PYTEST_TIMEOUT)
|
|
||||||
# def test_widgets_e2e_log_panel(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
|
||||||
# """Test the LogPanel widget."""
|
|
||||||
# gui = connected_client_gui_obj
|
|
||||||
# bec = gui._client
|
|
||||||
# # Create dock_area and widget
|
|
||||||
# widget = create_widget(qtbot, gui, gui.available_widgets.LogPanel)
|
|
||||||
# widget: client.LogPanel
|
|
||||||
|
|
||||||
# # No rpc calls to check so far
|
|
||||||
|
|
||||||
# # Test removing the widget, or leaving it open for the next test
|
|
||||||
# maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
@pytest.mark.timeout(PYTEST_TIMEOUT)
|
||||||
def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
def test_widgets_e2e_minesweeper(qtbot, connected_client_gui_obj, random_generator_from_seed):
|
||||||
"""Test the MineSweeper widget."""
|
"""Test the MineSweeper widget."""
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import shiboken6
|
||||||
|
from qtpy.QtCore import QEvent, QEventLoop, Qt
|
||||||
|
from qtpy.QtGui import QHideEvent, QShowEvent
|
||||||
|
from qtpy.QtTest import QTest
|
||||||
|
from qtpy.QtWidgets import QApplication, QWidget
|
||||||
|
|
||||||
|
import bec_widgets.widgets.editors.bec_console.bec_console as bec_console_module
|
||||||
|
from bec_widgets.widgets.editors.bec_console.bec_console import (
|
||||||
|
BecConsole,
|
||||||
|
BECShell,
|
||||||
|
ConsoleMode,
|
||||||
|
_bec_console_registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .client_mocks import mocked_client
|
||||||
|
|
||||||
|
|
||||||
|
def process_deferred_deletes():
|
||||||
|
app = QApplication.instance()
|
||||||
|
app.sendPostedEvents(None, QEvent.DeferredDelete)
|
||||||
|
app.processEvents(QEventLoop.AllEvents)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clean_bec_console_registry():
|
||||||
|
_bec_console_registry.clear()
|
||||||
|
yield
|
||||||
|
_bec_console_registry.clear()
|
||||||
|
process_deferred_deletes()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def console_widget(qtbot):
|
||||||
|
"""Create a BecConsole widget."""
|
||||||
|
widget = BecConsole(client=mocked_client, gui_id="test_console", terminal_id="test_terminal")
|
||||||
|
qtbot.addWidget(widget)
|
||||||
|
return widget
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def two_console_widgets_same_terminal(qtbot):
|
||||||
|
widget1 = BecConsole(client=mocked_client, gui_id="console_1", terminal_id="shared_terminal")
|
||||||
|
widget2 = BecConsole(client=mocked_client, gui_id="console_2", terminal_id="shared_terminal")
|
||||||
|
qtbot.addWidget(widget1)
|
||||||
|
qtbot.addWidget(widget2)
|
||||||
|
return widget1, widget2
|
||||||
|
|
||||||
|
|
||||||
|
def test_bec_console_initialization(console_widget: BecConsole):
|
||||||
|
assert console_widget.console_id == "test_console"
|
||||||
|
assert console_widget.terminal_id == "test_terminal"
|
||||||
|
assert console_widget._mode == ConsoleMode.ACTIVE
|
||||||
|
assert console_widget.term is not None
|
||||||
|
assert console_widget._overlay.isHidden()
|
||||||
|
console_widget.show()
|
||||||
|
assert console_widget.isVisible()
|
||||||
|
assert _bec_console_registry.owner_is_visible(console_widget.terminal_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bec_console_yield_terminal_ownership(console_widget):
|
||||||
|
console_widget.show()
|
||||||
|
console_widget.take_terminal_ownership()
|
||||||
|
console_widget.yield_ownership()
|
||||||
|
assert console_widget.term is None
|
||||||
|
assert console_widget._mode == ConsoleMode.INACTIVE
|
||||||
|
|
||||||
|
|
||||||
|
def test_bec_console_hide_event_yields_ownership(console_widget):
|
||||||
|
console_widget.take_terminal_ownership()
|
||||||
|
console_widget.hideEvent(QHideEvent())
|
||||||
|
assert console_widget.term is None
|
||||||
|
assert console_widget._mode == ConsoleMode.HIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_bec_console_show_event_takes_ownership(console_widget):
|
||||||
|
console_widget.yield_ownership()
|
||||||
|
console_widget.showEvent(QShowEvent())
|
||||||
|
assert console_widget.term is not None
|
||||||
|
assert console_widget._mode == ConsoleMode.ACTIVE
|
||||||
|
|
||||||
|
|
||||||
|
def test_bec_console_overlay_click_takes_ownership(qtbot, console_widget):
|
||||||
|
console_widget.yield_ownership()
|
||||||
|
assert console_widget._mode == ConsoleMode.HIDDEN
|
||||||
|
|
||||||
|
QTest.mouseClick(console_widget._overlay, Qt.LeftButton)
|
||||||
|
assert console_widget.term is not None
|
||||||
|
assert console_widget._mode == ConsoleMode.ACTIVE
|
||||||
|
assert not console_widget._overlay.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_consoles_shared_terminal(two_console_widgets_same_terminal):
|
||||||
|
widget1, widget2 = two_console_widgets_same_terminal
|
||||||
|
|
||||||
|
# Widget1 takes ownership
|
||||||
|
widget1.take_terminal_ownership()
|
||||||
|
assert widget1.term is not None
|
||||||
|
assert widget1._mode == ConsoleMode.ACTIVE
|
||||||
|
assert widget2.term is None
|
||||||
|
assert widget2._mode == ConsoleMode.HIDDEN
|
||||||
|
|
||||||
|
# Widget2 takes ownership
|
||||||
|
widget2.take_terminal_ownership()
|
||||||
|
assert widget2.term is not None
|
||||||
|
assert widget2._mode == ConsoleMode.ACTIVE
|
||||||
|
assert widget1.term is None
|
||||||
|
assert widget1._mode == ConsoleMode.HIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_bec_console_registry_cleanup(console_widget: BecConsole):
|
||||||
|
console_widget.take_terminal_ownership()
|
||||||
|
terminal_id = console_widget.terminal_id
|
||||||
|
|
||||||
|
assert terminal_id in _bec_console_registry._terminal_registry
|
||||||
|
_bec_console_registry.unregister(console_widget)
|
||||||
|
assert terminal_id not in _bec_console_registry._terminal_registry
|
||||||
|
|
||||||
|
|
||||||
|
def test_bec_shell_initialization(qtbot):
|
||||||
|
widget = BECShell(gui_id="bec_shell")
|
||||||
|
qtbot.addWidget(widget)
|
||||||
|
|
||||||
|
assert widget.console_id == "bec_shell"
|
||||||
|
assert widget.terminal_id == "bec_shell"
|
||||||
|
assert widget.startup_cmd is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bec_console_write(console_widget):
|
||||||
|
console_widget.take_terminal_ownership()
|
||||||
|
with mock.patch.object(console_widget.term, "write") as mock_write:
|
||||||
|
console_widget.write("test command")
|
||||||
|
mock_write.assert_called_once_with("test command", True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_owner(console_widget: BecConsole):
|
||||||
|
assert _bec_console_registry.is_owner(console_widget)
|
||||||
|
mock_console = mock.MagicMock()
|
||||||
|
mock_console.console_id = "fake_console"
|
||||||
|
_bec_console_registry._consoles["fake_console"] = mock_console
|
||||||
|
assert not _bec_console_registry.is_owner(mock_console)
|
||||||
|
mock_console.terminal_id = console_widget.terminal_id
|
||||||
|
assert not _bec_console_registry.is_owner(mock_console)
|
||||||
|
|
||||||
|
|
||||||
|
def test_closing_active_console_keeps_terminal_valid_for_remaining_console(qtbot):
|
||||||
|
widget1 = BecConsole(client=mocked_client, gui_id="close_owner", terminal_id="shared_close")
|
||||||
|
widget2 = BecConsole(client=mocked_client, gui_id="remaining", terminal_id="shared_close")
|
||||||
|
qtbot.addWidget(widget2)
|
||||||
|
|
||||||
|
widget1.take_terminal_ownership()
|
||||||
|
term = widget1.term
|
||||||
|
assert term is not None
|
||||||
|
|
||||||
|
widget1.close()
|
||||||
|
widget1.deleteLater()
|
||||||
|
process_deferred_deletes()
|
||||||
|
|
||||||
|
assert shiboken6.isValid(term)
|
||||||
|
widget2.take_terminal_ownership()
|
||||||
|
|
||||||
|
assert widget2.term is term
|
||||||
|
assert widget2._mode == ConsoleMode.ACTIVE
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_console_detaches_terminal_before_destruction(qtbot):
|
||||||
|
widget1 = BecConsole(client=mocked_client, gui_id="owner", terminal_id="shared_detach")
|
||||||
|
widget2 = BecConsole(client=mocked_client, gui_id="survivor", terminal_id="shared_detach")
|
||||||
|
qtbot.addWidget(widget1)
|
||||||
|
qtbot.addWidget(widget2)
|
||||||
|
|
||||||
|
widget1.take_terminal_ownership()
|
||||||
|
term = widget1.term
|
||||||
|
assert term is not None
|
||||||
|
assert widget1.isAncestorOf(term)
|
||||||
|
|
||||||
|
widget1.close()
|
||||||
|
|
||||||
|
assert shiboken6.isValid(term)
|
||||||
|
assert not widget1.isAncestorOf(term)
|
||||||
|
assert term.parent() is widget2._term_holder
|
||||||
|
|
||||||
|
|
||||||
|
def test_bec_shell_terminal_persists_after_last_shell_unregisters(qtbot):
|
||||||
|
shell = BECShell(gui_id="bec_shell_persistent")
|
||||||
|
qtbot.addWidget(shell)
|
||||||
|
term = shell.term
|
||||||
|
assert term is not None
|
||||||
|
|
||||||
|
_bec_console_registry.unregister(shell)
|
||||||
|
|
||||||
|
info = _bec_console_registry._terminal_registry["bec_shell"]
|
||||||
|
assert info.registered_console_ids == set()
|
||||||
|
assert info.owner_console_id is None
|
||||||
|
assert info.persist_session is True
|
||||||
|
assert info.instance is term
|
||||||
|
assert shiboken6.isValid(term)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_bec_shell_claims_preserved_terminal(qtbot):
|
||||||
|
shell1 = BECShell(gui_id="bec_shell_first")
|
||||||
|
term = shell1.term
|
||||||
|
assert term is not None
|
||||||
|
|
||||||
|
shell1.close()
|
||||||
|
shell1.deleteLater()
|
||||||
|
process_deferred_deletes()
|
||||||
|
|
||||||
|
assert "bec_shell" in _bec_console_registry._terminal_registry
|
||||||
|
assert shiboken6.isValid(term)
|
||||||
|
|
||||||
|
shell2 = BECShell(gui_id="bec_shell_second")
|
||||||
|
qtbot.addWidget(shell2)
|
||||||
|
shell2.showEvent(QShowEvent())
|
||||||
|
|
||||||
|
assert shell2.term is term
|
||||||
|
assert shell2._mode == ConsoleMode.ACTIVE
|
||||||
|
|
||||||
|
|
||||||
|
def test_persistent_bec_shell_sends_startup_command_once(qtbot, monkeypatch):
|
||||||
|
class RecordingTerminal(QWidget):
|
||||||
|
writes = []
|
||||||
|
|
||||||
|
def write(self, text: str, add_newline: bool = True):
|
||||||
|
self.writes.append((text, add_newline))
|
||||||
|
|
||||||
|
monkeypatch.setattr(bec_console_module, "_BecTermClass", RecordingTerminal)
|
||||||
|
|
||||||
|
shell1 = BECShell(gui_id="bec_shell_startup_first")
|
||||||
|
shell1.close()
|
||||||
|
shell1.deleteLater()
|
||||||
|
process_deferred_deletes()
|
||||||
|
|
||||||
|
shell2 = BECShell(gui_id="bec_shell_startup_second")
|
||||||
|
qtbot.addWidget(shell2)
|
||||||
|
shell2.showEvent(QShowEvent())
|
||||||
|
|
||||||
|
assert len(RecordingTerminal.writes) == 1
|
||||||
|
assert RecordingTerminal.writes[0][0].startswith("bec ")
|
||||||
|
assert RecordingTerminal.writes[0][1] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_console_terminal_removed_after_last_unregister(qtbot):
|
||||||
|
widget = BecConsole(client=mocked_client, gui_id="plain_console", terminal_id="plain_terminal")
|
||||||
|
qtbot.addWidget(widget)
|
||||||
|
|
||||||
|
assert "plain_terminal" in _bec_console_registry._terminal_registry
|
||||||
|
_bec_console_registry.unregister(widget)
|
||||||
|
|
||||||
|
assert "plain_terminal" not in _bec_console_registry._terminal_registry
|
||||||
@@ -9,7 +9,8 @@ from bec_widgets.cli.rpc.rpc_base import RPCBase
|
|||||||
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
|
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
|
||||||
|
|
||||||
|
|
||||||
class _TestGlobalPlugin(RPCBase): ...
|
class _TestGlobalPlugin(RPCBase):
|
||||||
|
_IMPORT_MODULE = "test.global.plugin.widgets"
|
||||||
|
|
||||||
|
|
||||||
mock_client_module_globals = SimpleNamespace()
|
mock_client_module_globals = SimpleNamespace()
|
||||||
@@ -25,12 +26,13 @@ mock_client_module_globals.Widgets = _TestGlobalPlugin
|
|||||||
def test_plugins_dont_clobber_client_globals(bec_logger: MagicMock):
|
def test_plugins_dont_clobber_client_globals(bec_logger: MagicMock):
|
||||||
reload(client)
|
reload(client)
|
||||||
bec_logger.logger.warning.assert_called_with(
|
bec_logger.logger.warning.assert_called_with(
|
||||||
"Plugin widget Widgets from namespace(Widgets=<class 'tests.unit_tests.test_client_plugin_widgets._TestGlobalPlugin'>) conflicts with a built-in class!"
|
"Plugin widget Widgets in test.global.plugin.widgets conflicts with a built-in class!"
|
||||||
)
|
)
|
||||||
assert isinstance(client.Widgets, enum.EnumType)
|
assert isinstance(client.Widgets, enum.EnumType)
|
||||||
|
|
||||||
|
|
||||||
class _TestDuplicatePlugin(RPCBase): ...
|
class _TestDuplicatePlugin(RPCBase):
|
||||||
|
_IMPORT_MODULE = "test.duplicate.plugin.module"
|
||||||
|
|
||||||
|
|
||||||
mock_client_module_duplicate = SimpleNamespace()
|
mock_client_module_duplicate = SimpleNamespace()
|
||||||
@@ -54,7 +56,7 @@ def test_duplicate_plugins_not_allowed(_, bec_logger: MagicMock):
|
|||||||
reload(client)
|
reload(client)
|
||||||
assert (
|
assert (
|
||||||
call(
|
call(
|
||||||
f"Detected duplicate widget Waveform in plugin repo file: {inspect.getfile(_TestDuplicatePlugin)} !"
|
"Plugin widget Waveform in test.duplicate.plugin.module conflicts with a built-in class!"
|
||||||
)
|
)
|
||||||
in bec_logger.logger.warning.mock_calls
|
in bec_logger.logger.warning.mock_calls
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -918,7 +918,7 @@ class TestToolbarFunctionality:
|
|||||||
action.trigger()
|
action.trigger()
|
||||||
if action_name == "terminal":
|
if action_name == "terminal":
|
||||||
mock_new.assert_called_once_with(
|
mock_new.assert_called_once_with(
|
||||||
widget="WebConsole", closable=True, startup_cmd=None
|
widget="BecConsole", closable=True, startup_cmd=None
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
mock_new.assert_called_once_with(widget=widget_type)
|
mock_new.assert_called_once_with(widget=widget_type)
|
||||||
@@ -2229,7 +2229,6 @@ class TestFlatToolbarActions:
|
|||||||
"flat_progress_bar",
|
"flat_progress_bar",
|
||||||
"flat_terminal",
|
"flat_terminal",
|
||||||
"flat_bec_shell",
|
"flat_bec_shell",
|
||||||
"flat_log_panel",
|
|
||||||
"flat_sbb_monitor",
|
"flat_sbb_monitor",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2272,7 +2271,7 @@ class TestFlatToolbarActions:
|
|||||||
"flat_queue": "BECQueue",
|
"flat_queue": "BECQueue",
|
||||||
"flat_status": "BECStatusBox",
|
"flat_status": "BECStatusBox",
|
||||||
"flat_progress_bar": "RingProgressBar",
|
"flat_progress_bar": "RingProgressBar",
|
||||||
"flat_terminal": "WebConsole",
|
"flat_terminal": "BecConsole",
|
||||||
"flat_bec_shell": "BECShell",
|
"flat_bec_shell": "BECShell",
|
||||||
"flat_sbb_monitor": "SBBMonitor",
|
"flat_sbb_monitor": "SBBMonitor",
|
||||||
}
|
}
|
||||||
@@ -2289,11 +2288,6 @@ class TestFlatToolbarActions:
|
|||||||
action.trigger()
|
action.trigger()
|
||||||
mock_new.assert_called_once_with(widget_type)
|
mock_new.assert_called_once_with(widget_type)
|
||||||
|
|
||||||
def test_flat_log_panel_action_disabled(self, advanced_dock_area):
|
|
||||||
"""Test that flat log panel action is disabled."""
|
|
||||||
action = advanced_dock_area.toolbar.components.get_action("flat_log_panel").action
|
|
||||||
assert not action.isEnabled()
|
|
||||||
|
|
||||||
|
|
||||||
class TestModeTransitions:
|
class TestModeTransitions:
|
||||||
"""Test mode transitions and state consistency."""
|
"""Test mode transitions and state consistency."""
|
||||||
|
|||||||
@@ -104,8 +104,7 @@ def test_client_generator_with_black_formatting():
|
|||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
|
|
||||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
|
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
|
||||||
from bec_widgets.utils.bec_plugin_helper import (get_all_plugin_widgets,
|
from bec_widgets.utils.bec_plugin_helper import get_plugin_client_module
|
||||||
get_plugin_client_module)
|
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
@@ -123,31 +122,25 @@ def test_client_generator_with_black_formatting():
|
|||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_plugin_widgets = get_all_plugin_widgets().as_dict()
|
|
||||||
plugin_client = get_plugin_client_module()
|
plugin_client = get_plugin_client_module()
|
||||||
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
|
|
||||||
|
|
||||||
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
|
|
||||||
for _widget in _overlap:
|
|
||||||
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
|
|
||||||
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
|
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
|
||||||
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
|
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
|
||||||
|
if plugin_name not in _Widgets:
|
||||||
|
_Widgets[plugin_name] = plugin_name
|
||||||
if plugin_name in globals():
|
if plugin_name in globals():
|
||||||
conflicting_file = (
|
|
||||||
inspect.getfile(_plugin_widgets[plugin_name])
|
|
||||||
if plugin_name in _plugin_widgets
|
|
||||||
else f"{plugin_client}"
|
|
||||||
)
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
|
f"Plugin widget {plugin_name} in {plugin_class._IMPORT_MODULE} conflicts with a built-in class!"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if plugin_name not in _overlap:
|
else:
|
||||||
globals()[plugin_name] = plugin_class
|
globals()[plugin_name] = plugin_class
|
||||||
|
Widgets = _WidgetsEnumType("Widgets", _Widgets)
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
|
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
|
||||||
|
|
||||||
class MockBECFigure(RPCBase):
|
class MockBECFigure(RPCBase):
|
||||||
|
_IMPORT_MODULE = "tests.unit_tests.test_generate_cli_client"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def add_plot(self, plot_id: str):
|
def add_plot(self, plot_id: str):
|
||||||
"""
|
"""
|
||||||
@@ -162,6 +155,8 @@ def test_client_generator_with_black_formatting():
|
|||||||
|
|
||||||
|
|
||||||
class MockBECWaveform1D(RPCBase):
|
class MockBECWaveform1D(RPCBase):
|
||||||
|
_IMPORT_MODULE = "tests.unit_tests.test_generate_cli_client"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_frequency(self, frequency: float) -> list:
|
def set_frequency(self, frequency: float) -> list:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+106
-146
@@ -7,163 +7,123 @@ from collections import deque
|
|||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from bec_lib.logger import LogLevel
|
||||||
from bec_lib.messages import LogMessage
|
from bec_lib.messages import LogMessage
|
||||||
from bec_lib.redis_connector import StreamMessage
|
|
||||||
from qtpy.QtCore import QDateTime
|
from qtpy.QtCore import QDateTime
|
||||||
|
|
||||||
from bec_widgets.widgets.utility.logpanel._util import (
|
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel, TimestampUpdate
|
||||||
log_time,
|
|
||||||
replace_escapes,
|
|
||||||
simple_color_format,
|
|
||||||
)
|
|
||||||
from bec_widgets.widgets.utility.logpanel.logpanel import DEFAULT_LOG_COLORS, LogPanel
|
|
||||||
|
|
||||||
from .client_mocks import mocked_client
|
from .client_mocks import mocked_client
|
||||||
|
|
||||||
TEST_TABLE_STRING = "2025-01-15 15:57:18 | bec_server.scan_server.scan_queue | [INFO] | \n \x1b[3m primary queue / ScanQueueStatus.RUNNING \x1b[0m\n┏━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┓\n┃\x1b[1m \x1b[0m\x1b[1m queue_id \x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mscan_id\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mis_scan\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mtype\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mscan_numb…\x1b[0m\x1b[1m \x1b[0m┃\x1b[1m \x1b[0m\x1b[1mIQ status\x1b[0m\x1b[1m \x1b[0m┃\n┡━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━┩\n│ bbe50c82-6… │ None │ False │ mv │ None │ PENDING │\n└─────────────┴─────────┴─────────┴──────┴────────────┴───────────┘\n\n"
|
|
||||||
|
|
||||||
TEST_LOG_MESSAGES = [
|
TEST_LOG_MESSAGES = [
|
||||||
LogMessage(
|
{"data": msg}
|
||||||
metadata={},
|
for msg in [
|
||||||
log_type="debug",
|
LogMessage(
|
||||||
log_msg={
|
|
||||||
"text": "datetime | debug | test log message",
|
|
||||||
"record": {"time": {"timestamp": 123456789.000}},
|
|
||||||
"service_name": "ScanServer",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
LogMessage(
|
|
||||||
metadata={},
|
|
||||||
log_type="info",
|
|
||||||
log_msg={
|
|
||||||
"text": "datetime | info | test log message",
|
|
||||||
"record": {"time": {"timestamp": 123456789.007}},
|
|
||||||
"service_name": "ScanServer",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
LogMessage(
|
|
||||||
metadata={},
|
|
||||||
log_type="success",
|
|
||||||
log_msg={
|
|
||||||
"text": "datetime | success | test log message",
|
|
||||||
"record": {"time": {"timestamp": 123456789.012}},
|
|
||||||
"service_name": "ScanServer",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
TEST_COMBINED_PLAINTEXT = "datetime | debug | test log message\ndatetime | info | test log message\ndatetime | success | test log message\n"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def raw_queue():
|
|
||||||
yield deque(TEST_LOG_MESSAGES, maxlen=100)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def log_panel(qtbot, mocked_client: MagicMock):
|
|
||||||
widget = LogPanel(client=mocked_client, service_status=MagicMock())
|
|
||||||
qtbot.addWidget(widget)
|
|
||||||
qtbot.waitExposed(widget)
|
|
||||||
yield widget
|
|
||||||
|
|
||||||
|
|
||||||
def test_log_panel_init(log_panel: LogPanel):
|
|
||||||
assert log_panel.plain_text == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_table_string_processing():
|
|
||||||
assert "\x1b" in TEST_TABLE_STRING
|
|
||||||
sanitized = replace_escapes(TEST_TABLE_STRING)
|
|
||||||
assert "\x1b" not in sanitized
|
|
||||||
assert " " not in sanitized
|
|
||||||
assert "\n" not in sanitized
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
["msg", "color"], zip(TEST_LOG_MESSAGES, ["#0000CC", "#FFFFFF", "#00FF00"])
|
|
||||||
)
|
|
||||||
def test_color_format(msg: LogMessage, color: str):
|
|
||||||
assert color in simple_color_format(msg, DEFAULT_LOG_COLORS)
|
|
||||||
|
|
||||||
|
|
||||||
def test_logpanel_output(qtbot, log_panel: LogPanel):
|
|
||||||
log_panel._log_manager._data = deque(TEST_LOG_MESSAGES)
|
|
||||||
log_panel._on_redraw()
|
|
||||||
assert log_panel.plain_text == TEST_COMBINED_PLAINTEXT
|
|
||||||
|
|
||||||
def display_queue_empty():
|
|
||||||
print(log_panel._log_manager._display_queue)
|
|
||||||
return len(log_panel._log_manager._display_queue) == 0
|
|
||||||
|
|
||||||
next_text = "datetime | error | test log message"
|
|
||||||
msg = LogMessage(
|
|
||||||
metadata={},
|
|
||||||
log_type="error",
|
|
||||||
log_msg={"text": next_text, "record": {}, "service_name": "ScanServer"},
|
|
||||||
)
|
|
||||||
log_panel._log_manager._process_incoming_log_msg(
|
|
||||||
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
|
|
||||||
)
|
|
||||||
|
|
||||||
qtbot.waitUntil(display_queue_empty, timeout=5000)
|
|
||||||
assert log_panel.plain_text == TEST_COMBINED_PLAINTEXT + next_text + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def test_level_filter(log_panel: LogPanel):
|
|
||||||
log_panel._log_manager._data = deque(TEST_LOG_MESSAGES)
|
|
||||||
log_panel._log_manager.update_level_filter("INFO")
|
|
||||||
log_panel._on_redraw()
|
|
||||||
assert (
|
|
||||||
log_panel.plain_text
|
|
||||||
== "datetime | info | test log message\ndatetime | success | test log message\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_clear_button(log_panel: LogPanel):
|
|
||||||
log_panel._log_manager._data = deque(TEST_LOG_MESSAGES)
|
|
||||||
log_panel.toolbar.clear_button.click()
|
|
||||||
assert log_panel._log_manager._data == deque([])
|
|
||||||
|
|
||||||
|
|
||||||
def test_timestamp_filter(log_panel: LogPanel):
|
|
||||||
log_panel._log_manager._timestamp_start = QDateTime(1973, 11, 29, 21, 33, 9, 5, 1)
|
|
||||||
pytest.approx(log_panel._log_manager._timestamp_start.toMSecsSinceEpoch() / 1000, 123456789.005)
|
|
||||||
log_panel._log_manager._timestamp_end = QDateTime(1973, 11, 29, 21, 33, 9, 10, 1)
|
|
||||||
pytest.approx(log_panel._log_manager._timestamp_end.toMSecsSinceEpoch() / 1000, 123456789.010)
|
|
||||||
filter_ = log_panel._log_manager._create_timestamp_filter()
|
|
||||||
assert not filter_(TEST_LOG_MESSAGES[0])
|
|
||||||
assert filter_(TEST_LOG_MESSAGES[1])
|
|
||||||
assert not filter_(TEST_LOG_MESSAGES[2])
|
|
||||||
|
|
||||||
|
|
||||||
def test_error_handling_in_callback(log_panel: LogPanel):
|
|
||||||
log_panel._log_manager.new_message = MagicMock()
|
|
||||||
|
|
||||||
with patch("bec_widgets.widgets.utility.logpanel.logpanel.logger") as logger:
|
|
||||||
# generally errors should be logged
|
|
||||||
log_panel._log_manager.new_message.emit = MagicMock(
|
|
||||||
side_effect=ValueError("Something went wrong")
|
|
||||||
)
|
|
||||||
msg = LogMessage(
|
|
||||||
metadata={},
|
metadata={},
|
||||||
log_type="debug",
|
log_type="debug",
|
||||||
log_msg={
|
log_msg={
|
||||||
"text": "datetime | debug | test log message",
|
"text": "datetime | debug | test log message",
|
||||||
"record": {"time": {"timestamp": 123456789.000}},
|
"record": {
|
||||||
|
"time": {"timestamp": 123456789.000, "repr": "2025-01-01 00:00:01"},
|
||||||
|
"message": "test debug message abcd",
|
||||||
|
"function": "_debug",
|
||||||
|
},
|
||||||
|
"service_name": "ScanServer",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
LogMessage(
|
||||||
|
metadata={},
|
||||||
|
log_type="info",
|
||||||
|
log_msg={
|
||||||
|
"text": "datetime | info | test info log message",
|
||||||
|
"record": {
|
||||||
|
"time": {"timestamp": 123456789.007, "repr": "2025-01-01 00:00:02"},
|
||||||
|
"message": "test info message efgh",
|
||||||
|
"function": "_info",
|
||||||
|
},
|
||||||
|
"service_name": "DeviceServer",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
LogMessage(
|
||||||
|
metadata={},
|
||||||
|
log_type="success",
|
||||||
|
log_msg={
|
||||||
|
"text": "datetime | success | test log message",
|
||||||
|
"record": {
|
||||||
|
"time": {"timestamp": 123456789.012, "repr": "2025-01-01 00:00:03"},
|
||||||
|
"message": "test success message ijkl",
|
||||||
|
"function": "_success",
|
||||||
|
},
|
||||||
|
"service_name": "ScanServer",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def log_panel(qtbot, mocked_client):
|
||||||
|
mocked_client.connector.xread = lambda *_, **__: TEST_LOG_MESSAGES
|
||||||
|
widget = LogPanel()
|
||||||
|
qtbot.addWidget(widget)
|
||||||
|
qtbot.waitExposed(widget)
|
||||||
|
yield widget
|
||||||
|
widget._model.log_queue.cleanup()
|
||||||
|
widget.close()
|
||||||
|
widget.deleteLater()
|
||||||
|
qtbot.wait(100)
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_panel_init(qtbot, log_panel: LogPanel):
|
||||||
|
assert log_panel
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_panel_filters(qtbot, log_panel: LogPanel):
|
||||||
|
assert log_panel._proxy.rowCount() == 3
|
||||||
|
# Service filter
|
||||||
|
log_panel._update_service_filter({"DeviceServer"})
|
||||||
|
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
|
||||||
|
log_panel._update_service_filter(set())
|
||||||
|
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
|
||||||
|
# Text filter
|
||||||
|
log_panel._proxy.update_filter_text("efgh")
|
||||||
|
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
|
||||||
|
log_panel._proxy.update_filter_text("")
|
||||||
|
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
|
||||||
|
# Time filter
|
||||||
|
log_panel._proxy.update_timestamp(
|
||||||
|
TimestampUpdate(value=QDateTime.fromMSecsSinceEpoch(123456789004), update_type="start")
|
||||||
|
)
|
||||||
|
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 2, timeout=200)
|
||||||
|
log_panel._proxy.update_timestamp(
|
||||||
|
TimestampUpdate(value=QDateTime.fromMSecsSinceEpoch(123456789009), update_type="end")
|
||||||
|
)
|
||||||
|
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
|
||||||
|
log_panel._proxy.update_timestamp(TimestampUpdate(value=None, update_type="start"))
|
||||||
|
log_panel._proxy.update_timestamp(TimestampUpdate(value=None, update_type="end"))
|
||||||
|
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
|
||||||
|
# Level filter
|
||||||
|
log_panel._proxy.update_level_filter(LogLevel.SUCCESS)
|
||||||
|
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 1, timeout=200)
|
||||||
|
log_panel._proxy.update_level_filter(None)
|
||||||
|
qtbot.waitUntil(lambda: log_panel._proxy.rowCount() == 3, timeout=200)
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_panel_update(qtbot, log_panel: LogPanel):
|
||||||
|
log_panel._model.log_queue._incoming.append(
|
||||||
|
LogMessage(
|
||||||
|
metadata={},
|
||||||
|
log_type="error",
|
||||||
|
log_msg={
|
||||||
|
"text": "datetime | error | test log message",
|
||||||
|
"record": {
|
||||||
|
"time": {"timestamp": 123456789.015, "repr": "2025-01-01 00:00:03"},
|
||||||
|
"message": "test error message xyz",
|
||||||
|
"function": "_error",
|
||||||
|
},
|
||||||
"service_name": "ScanServer",
|
"service_name": "ScanServer",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
log_panel._log_manager._process_incoming_log_msg(
|
)
|
||||||
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
|
log_panel._model.log_queue._proc_update()
|
||||||
)
|
qtbot.waitUntil(lambda: log_panel._model.rowCount() == 4, timeout=500)
|
||||||
logger.warning.assert_called_once()
|
|
||||||
|
|
||||||
# this specific error should be ignored and not relogged
|
|
||||||
log_panel._log_manager.new_message.emit = MagicMock(
|
|
||||||
side_effect=RuntimeError("Internal C++ object (BecLogsQueue) already deleted.")
|
|
||||||
)
|
|
||||||
log_panel._log_manager._process_incoming_log_msg(
|
|
||||||
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
|
|
||||||
)
|
|
||||||
logger.warning.assert_called_once()
|
|
||||||
|
|||||||
@@ -1,476 +0,0 @@
|
|||||||
from unittest import mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from qtpy.QtCore import Qt
|
|
||||||
from qtpy.QtGui import QHideEvent
|
|
||||||
from qtpy.QtNetwork import QAuthenticator
|
|
||||||
|
|
||||||
from bec_widgets.widgets.editors.web_console.web_console import (
|
|
||||||
BECShell,
|
|
||||||
ConsoleMode,
|
|
||||||
WebConsole,
|
|
||||||
_web_console_registry,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .client_mocks import mocked_client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mocked_server_startup():
|
|
||||||
"""Mock the web console server startup process."""
|
|
||||||
with mock.patch(
|
|
||||||
"bec_widgets.widgets.editors.web_console.web_console.subprocess"
|
|
||||||
) as mock_subprocess:
|
|
||||||
with mock.patch.object(_web_console_registry, "_wait_for_server_port"):
|
|
||||||
_web_console_registry._server_port = 12345
|
|
||||||
yield mock_subprocess
|
|
||||||
|
|
||||||
|
|
||||||
def static_console(qtbot, client, unique_id: str | None = None):
|
|
||||||
"""Fixture to provide a static unique_id for WebConsole tests."""
|
|
||||||
if unique_id is None:
|
|
||||||
widget = WebConsole(client=client)
|
|
||||||
else:
|
|
||||||
widget = WebConsole(client=client, unique_id=unique_id)
|
|
||||||
qtbot.addWidget(widget)
|
|
||||||
qtbot.waitExposed(widget)
|
|
||||||
return widget
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def console_widget(qtbot, mocked_client, mocked_server_startup):
|
|
||||||
"""Create a WebConsole widget with mocked server startup."""
|
|
||||||
yield static_console(qtbot, mocked_client)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def bec_shell_widget(qtbot, mocked_client, mocked_server_startup):
|
|
||||||
"""Create a BECShell widget with mocked server startup."""
|
|
||||||
widget = BECShell(client=mocked_client)
|
|
||||||
qtbot.addWidget(widget)
|
|
||||||
qtbot.waitExposed(widget)
|
|
||||||
yield widget
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def console_widget_with_static_id(qtbot, mocked_client, mocked_server_startup):
|
|
||||||
"""Create a WebConsole widget with a static unique ID."""
|
|
||||||
yield static_console(qtbot, mocked_client, unique_id="test_console")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def two_console_widgets_same_id(qtbot, mocked_client, mocked_server_startup):
|
|
||||||
"""Create two WebConsole widgets sharing the same unique ID."""
|
|
||||||
widget1 = static_console(qtbot, mocked_client, unique_id="shared_console")
|
|
||||||
widget2 = static_console(qtbot, mocked_client, unique_id="shared_console")
|
|
||||||
yield widget1, widget2
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_widget_initialization(console_widget):
|
|
||||||
assert (
|
|
||||||
console_widget.page.url().toString()
|
|
||||||
== f"http://localhost:{_web_console_registry._server_port}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_write(console_widget):
|
|
||||||
# Test the write method
|
|
||||||
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
|
|
||||||
console_widget.write("Hello, World!")
|
|
||||||
|
|
||||||
assert mock.call('window.term.paste("Hello, World!");') in mock_run_js.mock_calls
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_write_no_return(console_widget):
|
|
||||||
# Test the write method with send_return=False
|
|
||||||
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
|
|
||||||
console_widget.write("Hello, World!", send_return=False)
|
|
||||||
|
|
||||||
assert mock.call('window.term.paste("Hello, World!");') in mock_run_js.mock_calls
|
|
||||||
assert mock_run_js.call_count == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_send_return(console_widget):
|
|
||||||
# Test the send_return method
|
|
||||||
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
|
|
||||||
console_widget.send_return()
|
|
||||||
|
|
||||||
script = mock_run_js.call_args[0][0]
|
|
||||||
assert "new KeyboardEvent('keypress', {charCode: 13})" in script
|
|
||||||
assert mock_run_js.call_count == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_send_ctrl_c(console_widget):
|
|
||||||
# Test the send_ctrl_c method
|
|
||||||
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
|
|
||||||
console_widget.send_ctrl_c()
|
|
||||||
|
|
||||||
script = mock_run_js.call_args[0][0]
|
|
||||||
assert "new KeyboardEvent('keypress', {charCode: 3})" in script
|
|
||||||
assert mock_run_js.call_count == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_authenticate(console_widget):
|
|
||||||
# Test the _authenticate method
|
|
||||||
token = _web_console_registry._token
|
|
||||||
mock_auth = mock.MagicMock(spec=QAuthenticator)
|
|
||||||
console_widget._authenticate(None, mock_auth)
|
|
||||||
mock_auth.setUser.assert_called_once_with("user")
|
|
||||||
mock_auth.setPassword.assert_called_once_with(token)
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_registry_wait_for_server_port():
|
|
||||||
# Test the _wait_for_server_port method
|
|
||||||
with mock.patch.object(_web_console_registry, "_server_process") as mock_subprocess:
|
|
||||||
mock_subprocess.stderr.readline.side_effect = [b"Starting", b"Listening on port: 12345"]
|
|
||||||
_web_console_registry._wait_for_server_port()
|
|
||||||
assert _web_console_registry._server_port == 12345
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_registry_wait_for_server_port_timeout():
|
|
||||||
# Test the _wait_for_server_port method with timeout
|
|
||||||
with mock.patch.object(_web_console_registry, "_server_process") as mock_subprocess:
|
|
||||||
with pytest.raises(TimeoutError):
|
|
||||||
_web_console_registry._wait_for_server_port(timeout=0.1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_startup_command_execution(console_widget, qtbot):
|
|
||||||
"""Test that the startup command is triggered after successful initialization."""
|
|
||||||
# Set a custom startup command
|
|
||||||
console_widget.startup_cmd = "test startup command"
|
|
||||||
|
|
||||||
assert console_widget.startup_cmd == "test startup command"
|
|
||||||
|
|
||||||
# Generator to simulate JS initialization sequence
|
|
||||||
def js_readiness_sequence():
|
|
||||||
yield False # First call: not ready yet
|
|
||||||
while True:
|
|
||||||
yield True # Any subsequent calls: ready
|
|
||||||
|
|
||||||
readiness_gen = js_readiness_sequence()
|
|
||||||
|
|
||||||
def mock_run_js(script, callback=None):
|
|
||||||
# Check if this is the initialization check call
|
|
||||||
if "window.term !== undefined" in script and callback:
|
|
||||||
ready = next(readiness_gen)
|
|
||||||
callback(ready)
|
|
||||||
else:
|
|
||||||
# For other JavaScript calls (like paste), just call the callback
|
|
||||||
if callback:
|
|
||||||
callback(True)
|
|
||||||
|
|
||||||
with mock.patch.object(
|
|
||||||
console_widget.page, "runJavaScript", side_effect=mock_run_js
|
|
||||||
) as mock_run_js_method:
|
|
||||||
# Reset initialization state and start the timer
|
|
||||||
console_widget._is_initialized = False
|
|
||||||
console_widget._startup_timer.start()
|
|
||||||
|
|
||||||
# Wait for the initialization to complete
|
|
||||||
qtbot.waitUntil(lambda: console_widget._is_initialized, timeout=3000)
|
|
||||||
|
|
||||||
# Verify that the startup command was executed
|
|
||||||
startup_calls = [
|
|
||||||
call
|
|
||||||
for call in mock_run_js_method.call_args_list
|
|
||||||
if "test startup command" in str(call)
|
|
||||||
]
|
|
||||||
assert len(startup_calls) > 0, "Startup command should have been executed"
|
|
||||||
|
|
||||||
# Verify the initialized signal was emitted
|
|
||||||
assert console_widget._is_initialized is True
|
|
||||||
assert not console_widget._startup_timer.isActive()
|
|
||||||
|
|
||||||
|
|
||||||
def test_bec_shell_startup_contains_gui_id(bec_shell_widget):
|
|
||||||
"""Test that the BEC shell startup command includes the GUI ID."""
|
|
||||||
bec_shell = bec_shell_widget
|
|
||||||
|
|
||||||
assert bec_shell._is_bec_shell
|
|
||||||
assert bec_shell._unique_id == "bec_shell"
|
|
||||||
|
|
||||||
with mock.patch.object(bec_shell.bec_dispatcher, "cli_server", None):
|
|
||||||
assert bec_shell.startup_cmd == "bec --nogui"
|
|
||||||
|
|
||||||
with mock.patch.object(bec_shell.bec_dispatcher.cli_server, "gui_id", "test_gui_id"):
|
|
||||||
assert bec_shell.startup_cmd == "bec --gui-id test_gui_id"
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_set_readonly(console_widget):
|
|
||||||
# Test the set_readonly method
|
|
||||||
console_widget.set_readonly(True)
|
|
||||||
assert not console_widget.isEnabled()
|
|
||||||
|
|
||||||
console_widget.set_readonly(False)
|
|
||||||
assert console_widget.isEnabled()
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_with_unique_id(console_widget_with_static_id):
|
|
||||||
"""Test creating a WebConsole with a unique_id."""
|
|
||||||
widget = console_widget_with_static_id
|
|
||||||
|
|
||||||
assert widget._unique_id == "test_console"
|
|
||||||
assert widget._unique_id in _web_console_registry._page_registry
|
|
||||||
page_info = _web_console_registry.get_page_info("test_console")
|
|
||||||
assert page_info is not None
|
|
||||||
assert page_info.owner_gui_id == widget.gui_id
|
|
||||||
assert widget.gui_id in page_info.widget_ids
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_page_sharing(two_console_widgets_same_id):
|
|
||||||
"""Test that two widgets can share the same page using unique_id."""
|
|
||||||
widget1, widget2 = two_console_widgets_same_id
|
|
||||||
|
|
||||||
# Both should reference the same page in the registry
|
|
||||||
page_info = _web_console_registry.get_page_info("shared_console")
|
|
||||||
assert page_info is not None
|
|
||||||
assert widget1.gui_id in page_info.widget_ids
|
|
||||||
assert widget2.gui_id in page_info.widget_ids
|
|
||||||
assert widget1.page == widget2.page
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_has_ownership(console_widget_with_static_id):
|
|
||||||
"""Test the has_ownership method."""
|
|
||||||
widget = console_widget_with_static_id
|
|
||||||
|
|
||||||
# Widget should have ownership by default
|
|
||||||
assert widget.has_ownership()
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_yield_ownership(console_widget_with_static_id):
|
|
||||||
"""Test yielding ownership of a page."""
|
|
||||||
widget = console_widget_with_static_id
|
|
||||||
|
|
||||||
assert widget.has_ownership()
|
|
||||||
|
|
||||||
# Yield ownership
|
|
||||||
widget.yield_ownership()
|
|
||||||
|
|
||||||
# Widget should no longer have ownership
|
|
||||||
assert not widget.has_ownership()
|
|
||||||
page_info = _web_console_registry.get_page_info("test_console")
|
|
||||||
assert page_info.owner_gui_id is None
|
|
||||||
# Overlay should be shown
|
|
||||||
assert widget._mode == ConsoleMode.INACTIVE
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_take_page_ownership(two_console_widgets_same_id):
|
|
||||||
"""Test taking ownership of a page."""
|
|
||||||
widget1, widget2 = two_console_widgets_same_id
|
|
||||||
|
|
||||||
# Widget1 should have ownership initially
|
|
||||||
assert widget1.has_ownership()
|
|
||||||
assert not widget2.has_ownership()
|
|
||||||
|
|
||||||
# Widget2 takes ownership
|
|
||||||
widget2.take_page_ownership()
|
|
||||||
|
|
||||||
# Now widget2 should have ownership
|
|
||||||
assert not widget1.has_ownership()
|
|
||||||
assert widget2.has_ownership()
|
|
||||||
|
|
||||||
assert widget2._mode == ConsoleMode.ACTIVE
|
|
||||||
assert widget1._mode == ConsoleMode.INACTIVE
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_hide_event_yields_ownership(qtbot, console_widget_with_static_id):
|
|
||||||
"""Test that hideEvent yields ownership."""
|
|
||||||
widget = console_widget_with_static_id
|
|
||||||
|
|
||||||
assert widget.has_ownership()
|
|
||||||
|
|
||||||
# Hide the widget. Note that we cannot call widget.hide() directly
|
|
||||||
# because it doesn't trigger the hideEvent in tests as widgets are
|
|
||||||
# not visible in the test environment.
|
|
||||||
widget.hideEvent(QHideEvent())
|
|
||||||
qtbot.wait(100) # Allow event processing
|
|
||||||
|
|
||||||
# Widget should have yielded ownership
|
|
||||||
assert not widget.has_ownership()
|
|
||||||
page_info = _web_console_registry.get_page_info("test_console")
|
|
||||||
assert page_info.owner_gui_id is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_show_event_takes_ownership(console_widget_with_static_id):
|
|
||||||
"""Test that showEvent takes ownership when page has no owner."""
|
|
||||||
widget = console_widget_with_static_id
|
|
||||||
|
|
||||||
# Yield ownership
|
|
||||||
widget.yield_ownership()
|
|
||||||
assert not widget.has_ownership()
|
|
||||||
|
|
||||||
# Show the widget again
|
|
||||||
widget.show()
|
|
||||||
|
|
||||||
# Widget should have reclaimed ownership
|
|
||||||
assert widget.has_ownership()
|
|
||||||
assert widget.browser.isVisible()
|
|
||||||
assert not widget.overlay.isVisible()
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_mouse_press_takes_ownership(qtbot, two_console_widgets_same_id):
|
|
||||||
"""Test that clicking on overlay takes ownership."""
|
|
||||||
widget1, widget2 = two_console_widgets_same_id
|
|
||||||
widget1.show()
|
|
||||||
widget2.show()
|
|
||||||
|
|
||||||
# Widget1 has ownership, widget2 doesn't
|
|
||||||
assert widget1.has_ownership()
|
|
||||||
assert not widget2.has_ownership()
|
|
||||||
assert widget1.isVisible()
|
|
||||||
assert widget1._mode == ConsoleMode.ACTIVE
|
|
||||||
assert widget2._mode == ConsoleMode.INACTIVE
|
|
||||||
|
|
||||||
qtbot.mouseClick(widget2, Qt.MouseButton.LeftButton)
|
|
||||||
|
|
||||||
# Widget2 should now have ownership
|
|
||||||
assert widget2.has_ownership()
|
|
||||||
assert not widget1.has_ownership()
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_registry_cleanup_removes_page(console_widget_with_static_id):
|
|
||||||
"""Test that the registry cleans up pages when all widgets are removed."""
|
|
||||||
widget = console_widget_with_static_id
|
|
||||||
|
|
||||||
assert widget._unique_id in _web_console_registry._page_registry
|
|
||||||
|
|
||||||
# Cleanup the widget
|
|
||||||
widget.cleanup()
|
|
||||||
|
|
||||||
# Page should be removed from registry
|
|
||||||
assert widget._unique_id not in _web_console_registry._page_registry
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_without_unique_id_no_page_sharing(console_widget):
|
|
||||||
"""Test that widgets without unique_id don't participate in page sharing."""
|
|
||||||
widget = console_widget
|
|
||||||
|
|
||||||
# Widget should not be in the page registry
|
|
||||||
assert widget._unique_id is None
|
|
||||||
assert not widget.has_ownership() # Should return False for non-unique widgets
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_registry_get_page_info_nonexistent(qtbot, mocked_client):
|
|
||||||
"""Test getting page info for a non-existent page."""
|
|
||||||
page_info = _web_console_registry.get_page_info("nonexistent")
|
|
||||||
assert page_info is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_take_ownership_without_unique_id(console_widget):
|
|
||||||
"""Test that take_page_ownership fails gracefully without unique_id."""
|
|
||||||
widget = console_widget
|
|
||||||
# Should not crash when taking ownership without unique_id
|
|
||||||
widget.take_page_ownership()
|
|
||||||
|
|
||||||
|
|
||||||
def test_web_console_yield_ownership_without_unique_id(console_widget):
|
|
||||||
"""Test that yield_ownership fails gracefully without unique_id."""
|
|
||||||
widget = console_widget
|
|
||||||
# Should not crash when yielding ownership without unique_id
|
|
||||||
widget.yield_ownership()
|
|
||||||
|
|
||||||
|
|
||||||
def test_registry_yield_ownership_gui_id_not_in_instances():
|
|
||||||
"""Test registry yield_ownership returns False when gui_id not in instances."""
|
|
||||||
result = _web_console_registry.yield_ownership("nonexistent_gui_id")
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_registry_yield_ownership_instance_is_none(console_widget_with_static_id):
|
|
||||||
"""Test registry yield_ownership returns False when instance weakref is dead."""
|
|
||||||
widget = console_widget_with_static_id
|
|
||||||
gui_id = widget.gui_id
|
|
||||||
|
|
||||||
# Store the gui_id and simulate the weakref being dead
|
|
||||||
_web_console_registry._instances[gui_id] = lambda: None
|
|
||||||
|
|
||||||
result = _web_console_registry.yield_ownership(gui_id)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_registry_yield_ownership_unique_id_none(console_widget_with_static_id):
|
|
||||||
"""Test registry yield_ownership returns False when page info's unique_id is None."""
|
|
||||||
widget = console_widget_with_static_id
|
|
||||||
gui_id = widget.gui_id
|
|
||||||
unique_id = widget._unique_id
|
|
||||||
widget._unique_id = None
|
|
||||||
|
|
||||||
result = _web_console_registry.yield_ownership(gui_id)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
widget._unique_id = unique_id # Restore for cleanup
|
|
||||||
|
|
||||||
|
|
||||||
def test_registry_yield_ownership_unique_id_not_in_page_registry(console_widget_with_static_id):
|
|
||||||
"""Test registry yield_ownership returns False when unique_id not in page registry."""
|
|
||||||
widget = console_widget_with_static_id
|
|
||||||
gui_id = widget.gui_id
|
|
||||||
unique_id = widget._unique_id
|
|
||||||
widget._unique_id = "nonexistent_unique_id"
|
|
||||||
|
|
||||||
result = _web_console_registry.yield_ownership(gui_id)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
widget._unique_id = unique_id # Restore for cleanup
|
|
||||||
|
|
||||||
|
|
||||||
def test_registry_owner_is_visible_page_info_none():
|
|
||||||
"""Test owner_is_visible returns False when page info doesn't exist."""
|
|
||||||
result = _web_console_registry.owner_is_visible("nonexistent_page")
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_registry_owner_is_visible_no_owner(console_widget_with_static_id):
|
|
||||||
"""Test owner_is_visible returns False when page has no owner."""
|
|
||||||
widget = console_widget_with_static_id
|
|
||||||
|
|
||||||
# Yield ownership so there's no owner
|
|
||||||
widget.yield_ownership()
|
|
||||||
page_info = _web_console_registry.get_page_info(widget._unique_id)
|
|
||||||
assert page_info.owner_gui_id is None
|
|
||||||
|
|
||||||
result = _web_console_registry.owner_is_visible(widget._unique_id)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_registry_owner_is_visible_owner_ref_none(console_widget_with_static_id):
|
|
||||||
"""Test owner_is_visible returns False when owner ref doesn't exist in instances."""
|
|
||||||
widget = console_widget_with_static_id
|
|
||||||
unique_id = widget._unique_id
|
|
||||||
|
|
||||||
# Remove owner from instances dict
|
|
||||||
del _web_console_registry._instances[widget.gui_id]
|
|
||||||
|
|
||||||
result = _web_console_registry.owner_is_visible(unique_id)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_registry_owner_is_visible_owner_instance_none(console_widget_with_static_id):
|
|
||||||
"""Test owner_is_visible returns False when owner instance weakref is dead."""
|
|
||||||
widget = console_widget_with_static_id
|
|
||||||
unique_id = widget._unique_id
|
|
||||||
gui_id = widget.gui_id
|
|
||||||
|
|
||||||
# Simulate dead weakref
|
|
||||||
_web_console_registry._instances[gui_id] = lambda: None
|
|
||||||
|
|
||||||
result = _web_console_registry.owner_is_visible(unique_id)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_registry_owner_is_visible_owner_visible(console_widget_with_static_id):
|
|
||||||
"""Test owner_is_visible returns True when owner is visible."""
|
|
||||||
widget = console_widget_with_static_id
|
|
||||||
widget.show()
|
|
||||||
|
|
||||||
result = _web_console_registry.owner_is_visible(widget._unique_id)
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_registry_owner_is_visible_owner_not_visible(console_widget_with_static_id):
|
|
||||||
"""Test owner_is_visible returns False when owner is not visible."""
|
|
||||||
widget = console_widget_with_static_id
|
|
||||||
widget.hide()
|
|
||||||
|
|
||||||
result = _web_console_registry.owner_is_visible(widget._unique_id)
|
|
||||||
assert result is False
|
|
||||||
Reference in New Issue
Block a user