From cb2ccb02ffab6741dcfc2d8119dc5521bc8160fb Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 17 Oct 2025 13:40:42 +0200 Subject: [PATCH] feat(jupyter_console_window): adjustment for general usage --- .../jupyter_console/jupyter_console_window.py | 471 +++++++++++++----- .../jupyter_console/jupyter_console.py | 49 +- 2 files changed, 388 insertions(+), 132 deletions(-) diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py index b5e92563..ed0dc035 100644 --- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py +++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py @@ -1,12 +1,23 @@ +from __future__ import annotations + +import ast +import importlib import os +from typing import Any, Dict import numpy as np import pyqtgraph as pg from bec_qthemes import material_icon +from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QApplication, + QComboBox, + QFrame, + QGridLayout, QGroupBox, QHBoxLayout, + QLabel, + QLineEdit, QPushButton, QSplitter, QTabWidget, @@ -14,148 +25,359 @@ from qtpy.QtWidgets import ( QWidget, ) -from bec_widgets.utils import BECDispatcher +from bec_widgets import BECWidget +from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.widget_io import WidgetHierarchy as wh -from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea from bec_widgets.widgets.containers.dock import BECDockArea -from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole -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.multi_waveform.multi_waveform import MultiWaveform -from bec_widgets.widgets.plots.plot_base import PlotBase -from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform -from bec_widgets.widgets.plots.waveform.waveform import Waveform class JupyterConsoleWindow(QWidget): # pragma: no cover: - """A widget that contains a Jupyter console linked to BEC Widgets with full API access (contains Qt and pyqtgraph API).""" + """A widget that contains a Jupyter console linked to BEC Widgets with full API access. - def __init__(self, parent=None): - super().__init__(parent) + Features: + - Add widgets dynamically from the UI (top-right panel) or from the console via `jc.add_widget(...)`. + - Add BEC widgets by registered type via a combo box or `jc.add_widget_by_type(...)`. + - Each added widget appears as a new tab in the left tab widget and is exposed in the console under the chosen shortcut. + - Hardcoded example tabs removed; two examples are added programmatically at startup in the __main__ block. + """ + def __init__(self, parent=None, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + + self._widgets_by_name: Dict[str, QWidget] = {} self._init_ui() - # console push + # expose helper API and basics in the inprocess console if self.console.inprocess is True: - self.console.kernel_manager.kernel.shell.push( - { - "np": np, - "pg": pg, - "wh": wh, - "dock": self.dock, - "im": self.im, - "ads": self.ads, - # "mi": self.mi, - # "mm": self.mm, - # "lm": self.lm, - # "btn1": self.btn1, - # "btn2": self.btn2, - # "btn3": self.btn3, - # "btn4": self.btn4, - # "btn5": self.btn5, - # "btn6": self.btn6, - # "pb": self.pb, - # "pi": self.pi, - "wf": self.wf, - # "scatter": self.scatter, - # "scatter_mi": self.scatter, - # "mwf": self.mwf, - } - ) + # A thin API wrapper so users have a stable, minimal surface in the console + class _ConsoleAPI: + def __init__(self, win: "JupyterConsoleWindow"): + self._win = win + + def add_widget(self, widget: QWidget, shortcut: str, title: str | None = None): + """Add an existing QWidget as a new tab and expose it in the console under `shortcut`.""" + return self._win.add_widget(widget, shortcut, title=title) + + def add_widget_by_class_path( + self, + class_path: str, + shortcut: str, + kwargs: dict | None = None, + title: str | None = None, + ): + """Import a QWidget class from `class_path`, instantiate it, and add it.""" + return self._win.add_widget_by_class_path( + class_path, shortcut, kwargs=kwargs, title=title + ) + + def add_widget_by_type( + self, + widget_type: str, + shortcut: str, + kwargs: dict | None = None, + title: str | None = None, + ): + """Instantiate a registered BEC widget by type string and add it.""" + return self._win.add_widget_by_type( + widget_type, shortcut, kwargs=kwargs, title=title + ) + + def list_widgets(self): + return list(self._win._widgets_by_name.keys()) + + def get_widget(self, shortcut: str) -> QWidget | None: + return self._win._widgets_by_name.get(shortcut) + + def available_widgets(self): + return list(widget_handler.widget_classes.keys()) + + self.jc = _ConsoleAPI(self) + self._push_to_console({"jc": self.jc, "np": np, "pg": pg, "wh": wh}) def _init_ui(self): self.layout = QHBoxLayout(self) - # Horizontal splitter + # Horizontal splitter: left = widgets tabs, right = console + add-widget panel splitter = QSplitter(self) self.layout.addWidget(splitter) - tab_widget = QTabWidget(splitter) + # Left: tabs that will host dynamically added widgets + self.tab_widget = QTabWidget(splitter) - first_tab = QWidget() - first_tab_layout = QVBoxLayout(first_tab) - self.dock = BECDockArea(gui_id="dock") - first_tab_layout.addWidget(self.dock) - tab_widget.addTab(first_tab, "Dock Area") + # Right: console area with an add-widget mini panel on top + right_panel = QGroupBox("Jupyter Console", splitter) + right_layout = QVBoxLayout(right_panel) + right_layout.setContentsMargins(6, 12, 6, 6) - # third_tab = QWidget() - # third_tab_layout = QVBoxLayout(third_tab) - # self.lm = LayoutManagerWidget() - # third_tab_layout.addWidget(self.lm) - # tab_widget.addTab(third_tab, "Layout Manager Widget") - # - # fourth_tab = QWidget() - # fourth_tab_layout = QVBoxLayout(fourth_tab) - # self.pb = PlotBase() - # self.pi = self.pb.plot_item - # fourth_tab_layout.addWidget(self.pb) - # tab_widget.addTab(fourth_tab, "PlotBase") - # - # tab_widget.setCurrentIndex(3) - # - group_box = QGroupBox("Jupyter Console", splitter) - group_box_layout = QVBoxLayout(group_box) + # Add-widget mini panel + add_panel = QFrame(right_panel) + shape = QFrame.Shape.StyledPanel # PySide6 style enums + add_panel.setFrameShape(shape) + add_grid = QGridLayout(add_panel) + add_grid.setContentsMargins(8, 8, 8, 8) + add_grid.setHorizontalSpacing(8) + add_grid.setVerticalSpacing(6) + + instr = QLabel( + "Add a widget by class path or choose a registered BEC widget type," + " and expose it in the console under a shortcut.\n" + "Example class path: bec_widgets.widgets.plots.waveform.waveform.Waveform" + ) + instr.setWordWrap(True) + add_grid.addWidget(instr, 0, 0, 1, 2) + + # Registered widget selector + reg_label = QLabel("Registered") + reg_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.registry_combo = QComboBox(add_panel) + self.registry_combo.setEditable(False) + self.refresh_btn = QPushButton("Refresh") + reg_row = QHBoxLayout() + reg_row.addWidget(self.registry_combo) + reg_row.addWidget(self.refresh_btn) + add_grid.addWidget(reg_label, 1, 0) + add_grid.addLayout(reg_row, 1, 1) + + # Class path entry + class_label = QLabel("Class") + class_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.class_path_edit = QLineEdit(add_panel) + self.class_path_edit.setPlaceholderText("Fully-qualified class path (e.g. pkg.mod.Class)") + add_grid.addWidget(class_label, 2, 0) + add_grid.addWidget(self.class_path_edit, 2, 1) + + # Shortcut + shortcut_label = QLabel("Shortcut") + shortcut_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.shortcut_edit = QLineEdit(add_panel) + self.shortcut_edit.setPlaceholderText("Shortcut in console (variable name)") + add_grid.addWidget(shortcut_label, 3, 0) + add_grid.addWidget(self.shortcut_edit, 3, 1) + + # Kwargs + kwargs_label = QLabel("Kwargs") + kwargs_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.kwargs_edit = QLineEdit(add_panel) + self.kwargs_edit.setPlaceholderText( + 'Optional kwargs as dict literal, e.g. {"popups": True}' + ) + add_grid.addWidget(kwargs_label, 4, 0) + add_grid.addWidget(self.kwargs_edit, 4, 1) + + # Title + title_label = QLabel("Title") + title_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.title_edit = QLineEdit(add_panel) + self.title_edit.setPlaceholderText("Optional tab title (defaults to Shortcut or Class)") + add_grid.addWidget(title_label, 5, 0) + add_grid.addWidget(self.title_edit, 5, 1) + + # Buttons + btn_row = QHBoxLayout() + self.add_btn = QPushButton("Add by class path") + self.add_btn.clicked.connect(self._on_add_widget_clicked) + self.add_reg_btn = QPushButton("Add registered") + self.add_reg_btn.clicked.connect(self._on_add_registered_clicked) + btn_row.addStretch(1) + btn_row.addWidget(self.add_reg_btn) + btn_row.addWidget(self.add_btn) + add_grid.addLayout(btn_row, 6, 0, 1, 2) + + # Make the second column expand + add_grid.setColumnStretch(0, 0) + add_grid.setColumnStretch(1, 1) + + # Console widget self.console = BECJupyterConsole(inprocess=True) - group_box_layout.addWidget(self.console) - # - # # Some buttons for layout testing - # self.btn1 = QPushButton("Button 1") - # self.btn2 = QPushButton("Button 2") - # self.btn3 = QPushButton("Button 3") - # self.btn4 = QPushButton("Button 4") - # self.btn5 = QPushButton("Button 5") - # self.btn6 = QPushButton("Button 6") - # - fifth_tab = QWidget() - fifth_tab_layout = QVBoxLayout(fifth_tab) - self.wf = Waveform() - fifth_tab_layout.addWidget(self.wf) - tab_widget.addTab(fifth_tab, "Waveform Next Gen") - # - sixth_tab = QWidget() - sixth_tab_layout = QVBoxLayout(sixth_tab) - self.im = Image(popups=True) - self.mi = self.im.main_image - sixth_tab_layout.addWidget(self.im) - tab_widget.addTab(sixth_tab, "Image Next Gen") - tab_widget.setCurrentIndex(1) - # - seventh_tab = QWidget() - seventh_tab_layout = QVBoxLayout(seventh_tab) - self.ads = AdvancedDockArea(gui_id="ads") - seventh_tab_layout.addWidget(self.ads) - tab_widget.addTab(seventh_tab, "ADS") - tab_widget.setCurrentIndex(2) - # - # eighth_tab = QWidget() - # eighth_tab_layout = QVBoxLayout(eighth_tab) - # self.mm = MotorMap() - # eighth_tab_layout.addWidget(self.mm) - # tab_widget.addTab(eighth_tab, "Motor Map") - # tab_widget.setCurrentIndex(7) - # - # ninth_tab = QWidget() - # ninth_tab_layout = QVBoxLayout(ninth_tab) - # self.mwf = MultiWaveform() - # ninth_tab_layout.addWidget(self.mwf) - # tab_widget.addTab(ninth_tab, "MultiWaveform") - # tab_widget.setCurrentIndex(8) - # - # # add stuff to the new Waveform widget - # self._init_waveform() - # - # self.setWindowTitle("Jupyter Console Window") - def _init_waveform(self): - self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel") - self.wf.plot(y_name="bpm3a", y_entry="bpm3a", dap="GaussianModel") + # Vertical splitter between add panel and console + right_splitter = QSplitter(Qt.Vertical, right_panel) + right_splitter.addWidget(add_panel) + right_splitter.addWidget(self.console) + right_splitter.setStretchFactor(0, 0) + right_splitter.setStretchFactor(1, 1) + right_splitter.setSizes([300, 600]) + + # Put splitter into the right group box + right_layout.addWidget(right_splitter) + + # Populate registry on startup + self._populate_registry_widgets() + + def _populate_registry_widgets(self): + try: + widget_handler.update_available_widgets() + items = sorted(widget_handler.widget_classes.keys()) + except Exception as exc: + print(f"Failed to load registered widgets: {exc}") + items = [] + self.registry_combo.clear() + self.registry_combo.addItems(items) + + def _on_add_widget_clicked(self): + class_path = self.class_path_edit.text().strip() + shortcut = self.shortcut_edit.text().strip() + kwargs_text = self.kwargs_edit.text().strip() + title = self.title_edit.text().strip() or None + + if not class_path or not shortcut: + print("Please provide both class path and shortcut.") + return + + kwargs: dict | None = None + if kwargs_text: + try: + parsed = ast.literal_eval(kwargs_text) + if isinstance(parsed, dict): + kwargs = parsed + else: + print("Kwargs must be a Python dict literal, ignoring input.") + except Exception as exc: + print(f"Failed to parse kwargs: {exc}") + + try: + widget = self._instantiate_from_class_path(class_path, kwargs=kwargs) + except Exception as exc: + print(f"Failed to instantiate {class_path}: {exc}") + return + + try: + self.add_widget(widget, shortcut, title=title) + except Exception as exc: + print(f"Failed to add widget: {exc}") + return + + # focus the newly added tab + idx = self.tab_widget.count() - 1 + if idx >= 0: + self.tab_widget.setCurrentIndex(idx) + + def _on_add_registered_clicked(self): + widget_type = self.registry_combo.currentText().strip() + shortcut = self.shortcut_edit.text().strip() + kwargs_text = self.kwargs_edit.text().strip() + title = self.title_edit.text().strip() or None + + if not widget_type or not shortcut: + print("Please select a registered widget and provide a shortcut.") + return + + kwargs: dict | None = None + if kwargs_text: + try: + parsed = ast.literal_eval(kwargs_text) + if isinstance(parsed, dict): + kwargs = parsed + else: + print("Kwargs must be a Python dict literal, ignoring input.") + except Exception as exc: + print(f"Failed to parse kwargs: {exc}") + + try: + self.add_widget_by_type(widget_type, shortcut, kwargs=kwargs, title=title) + except Exception as exc: + print(f"Failed to add registered widget: {exc}") + return + + # focus the newly added tab + idx = self.tab_widget.count() - 1 + if idx >= 0: + self.tab_widget.setCurrentIndex(idx) + + def _instantiate_from_class_path(self, class_path: str, kwargs: dict | None = None) -> QWidget: + module_path, _, class_name = class_path.rpartition(".") + if not module_path or not class_name: + raise ValueError("class_path must be of the form 'package.module.Class'") + module = importlib.import_module(module_path) + cls = getattr(module, class_name) + if kwargs is None: + obj = cls() + else: + obj = cls(**kwargs) + if not isinstance(obj, QWidget): + raise TypeError(f"Instantiated object from {class_path} is not a QWidget: {type(obj)}") + return obj + + def add_widget(self, widget: QWidget, shortcut: str, title: str | None = None) -> QWidget: + """Add a QWidget as a new tab and expose it in the Jupyter console. + + - widget: a QWidget instance to host in a new tab + - shortcut: variable name used in the console to access it + - title: optional tab title (defaults to shortcut or class name) + """ + if not isinstance(widget, QWidget): + raise TypeError("widget must be a QWidget instance") + if not shortcut or not shortcut.isidentifier(): + raise ValueError("shortcut must be a valid Python identifier") + if shortcut in self._widgets_by_name: + raise ValueError(f"A widget with shortcut '{shortcut}' already exists") + if self.console.inprocess is not True: + raise RuntimeError("Adding widgets and exposing them requires inprocess console") + + tab_title = title or shortcut or widget.__class__.__name__ + self.tab_widget.addTab(widget, tab_title) + self._widgets_by_name[shortcut] = widget + + # Expose in console under the given shortcut + self._push_to_console({shortcut: widget}) + return widget + + def add_widget_by_class_path( + self, class_path: str, shortcut: str, kwargs: dict | None = None, title: str | None = None + ) -> QWidget: + widget = self._instantiate_from_class_path(class_path, kwargs=kwargs) + return self.add_widget(widget, shortcut, title=title) + + def add_widget_by_type( + self, widget_type: str, shortcut: str, kwargs: dict | None = None, title: str | None = None + ) -> QWidget: + """Instantiate a registered BEC widget by its type string and add it as a tab. + + If kwargs does not contain `object_name`, it will default to the provided shortcut. + """ + # Ensure registry is loaded + widget_handler.update_available_widgets() + cls = widget_handler.widget_classes.get(widget_type) + if cls is None: + raise ValueError(f"Unknown registered widget type: {widget_type}") + + if kwargs is None: + kwargs = {"object_name": shortcut} + else: + kwargs = dict(kwargs) + kwargs.setdefault("object_name", shortcut) + + # Instantiate and add + widget = cls(**kwargs) + if not isinstance(widget, QWidget): + raise TypeError( + f"Instantiated object for type '{widget_type}' is not a QWidget: {type(widget)}" + ) + return self.add_widget(widget, shortcut, title=title) + + def _push_to_console(self, mapping: Dict[str, Any]): + """Push Python objects into the inprocess kernel user namespace.""" + if self.console.inprocess is True: + self.console.kernel_manager.kernel.shell.push(mapping) + else: + raise RuntimeError("Can only push variables when using inprocess kernel") def closeEvent(self, event): """Override to handle things when main window is closed.""" - self.dock.cleanup() - self.dock.close() + # clean up any widgets that might have custom cleanup + try: + # call cleanup on known containers if present + dock = self._widgets_by_name.get("dock") + if isinstance(dock, BECDockArea): + dock.cleanup() + dock.close() + except Exception: + pass + + # Ensure the embedded kernel and BEC client are shut down before window teardown + self.console.shutdown_kernel() self.console.close() super().closeEvent(event) @@ -175,13 +397,20 @@ if __name__ == "__main__": # pragma: no cover icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True) app.setWindowIcon(icon) - bec_dispatcher = BECDispatcher(gui_id="jupyter_console") - client = bec_dispatcher.client - client.start() - win = JupyterConsoleWindow() + + # Examples: add two widgets programmatically to demonstrate usage + try: + win.add_widget_by_type("Waveform", shortcut="wf") + except Exception as exc: + print(f"Example add failed (Waveform by type): {exc}") + + try: + win.add_widget_by_type("Image", shortcut="im", kwargs={"popups": True}) + except Exception as exc: + print(f"Example add failed (Image by type): {exc}") + win.show() win.resize(1500, 800) - app.aboutToQuit.connect(win.close) sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/editors/jupyter_console/jupyter_console.py b/bec_widgets/widgets/editors/jupyter_console/jupyter_console.py index 24234db6..e5322689 100644 --- a/bec_widgets/widgets/editors/jupyter_console/jupyter_console.py +++ b/bec_widgets/widgets/editors/jupyter_console/jupyter_console.py @@ -2,6 +2,7 @@ from bec_ipython_client.main import BECIPythonClient from qtconsole.inprocess import QtInProcessKernelManager from qtconsole.manager import QtKernelManager from qtconsole.rich_jupyter_widget import RichJupyterWidget +from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication, QMainWindow @@ -9,10 +10,10 @@ class BECJupyterConsole(RichJupyterWidget): # pragma: no cover: def __init__(self, inprocess: bool = False): super().__init__() - self.inprocess = None - self.client = None + self.inprocess = inprocess + self.ipyclient = None - self.kernel_manager, self.kernel_client = self._init_kernel(inprocess=inprocess) + self.kernel_manager, self.kernel_client = self._init_kernel(inprocess=self.inprocess) self.set_default_style("linux") self._init_bec() @@ -35,14 +36,13 @@ class BECJupyterConsole(RichJupyterWidget): # pragma: no cover: self._init_bec_kernel() def _init_bec_inprocess(self): - self.client = BECIPythonClient() - self.client.start() - + self.ipyclient = BECIPythonClient() + self.ipyclient.start() self.kernel_manager.kernel.shell.push( { - "bec": self.client, - "dev": self.client.device_manager.devices, - "scans": self.client.scans, + "bec": self.ipyclient, + "dev": self.ipyclient.device_manager.devices, + "scans": self.ipyclient.scans, } ) @@ -57,20 +57,47 @@ class BECJupyterConsole(RichJupyterWidget): # pragma: no cover: """ ) + def _cleanup_bec(self): + if getattr(self, "ipyclient", None) is not None and self.inprocess is True: + self.ipyclient.shutdown() + self.ipyclient = None + def shutdown_kernel(self): + """ + Shutdown the Jupyter kernel and clean up resources. + """ + self._cleanup_bec() self.kernel_client.stop_channels() self.kernel_manager.shutdown_kernel() + self.kernel_client = None + self.kernel_manager = None def closeEvent(self, event): self.shutdown_kernel() + event.accept() + super().closeEvent(event) + + +class JupyterConsoleWindow(QMainWindow): # pragma: no cover: + def __init__(self, inprocess: bool = True, parent=None): + super().__init__(parent) + self.console = BECJupyterConsole(inprocess=inprocess) + self.setCentralWidget(self.console) + self.setAttribute(Qt.WA_DeleteOnClose, True) + + def closeEvent(self, event): + # Explicitly close the console so its own closeEvent runs + if getattr(self, "console", None) is not None: + self.console.close() + event.accept() + super().closeEvent(event) if __name__ == "__main__": # pragma: no cover import sys app = QApplication(sys.argv) - win = QMainWindow() - win.setCentralWidget(BECJupyterConsole(True)) + win = JupyterConsoleWindow(inprocess=True) win.show() sys.exit(app.exec_())