From 480637f7cd0bb46c1d1594eadd165b818aae7ab5 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 16 Oct 2026 16:55:20 +0000 Subject: [PATCH] refactor(forms): remove the old TypedForm and PydanticModelForm stack --- bec_widgets/utils/forms_from_types/forms.py | 420 ----------- bec_widgets/utils/forms_from_types/items.py | 701 ------------------ bec_widgets/utils/forms_from_types/styles.py | 28 - .../widgets/editors/dict_backed_table.py | 281 ------- .../widgets/editors/scan_metadata/_util.py | 72 -- .../test_dict_backed_table_model.py | 202 ----- tests/unit_tests/test_generated_form_form.py | 66 -- tests/unit_tests/test_generated_form_items.py | 122 --- tests/unit_tests/test_scan_metadata.py | 43 -- 9 files changed, 1935 deletions(-) delete mode 100644 bec_widgets/utils/forms_from_types/forms.py delete mode 100644 bec_widgets/utils/forms_from_types/items.py delete mode 100644 bec_widgets/utils/forms_from_types/styles.py delete mode 100644 bec_widgets/widgets/editors/dict_backed_table.py delete mode 100644 bec_widgets/widgets/editors/scan_metadata/_util.py delete mode 100644 tests/unit_tests/test_dict_backed_table_model.py delete mode 100644 tests/unit_tests/test_generated_form_items.py diff --git a/bec_widgets/utils/forms_from_types/forms.py b/bec_widgets/utils/forms_from_types/forms.py deleted file mode 100644 index 850bddf0..00000000 --- a/bec_widgets/utils/forms_from_types/forms.py +++ /dev/null @@ -1,420 +0,0 @@ -from __future__ import annotations - -from types import GenericAlias, NoneType, UnionType -from typing import NamedTuple, Union, get_args, get_origin - -from bec_lib.logger import bec_logger -from pydantic import BaseModel, ValidationError -from qtpy.QtCore import Qt, Signal # type: ignore -from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget - -from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.colors import get_accent_colors -from bec_widgets.utils.error_popups import SafeProperty, SafeSlot -from bec_widgets.utils.forms_from_types import styles -from bec_widgets.utils.forms_from_types.items import ( - DynamicFormItem, - DynamicFormItemType, - FormItemSpec, - widget_from_type, -) - -logger = bec_logger.logger - - -class GridRow(NamedTuple): - i: int - label: QLabel - widget: DynamicFormItem - - -class ValidationIssue(NamedTuple): - """One invalid field of a form, ready for display.""" - - field: str - title: str - problem: str - entered: str - - -class TypedForm(BECWidget, QWidget): - PLUGIN = True - ICON_NAME = "list_alt" - - value_changed = Signal() - - RPC = True - USER_ACCESS = ["enabled", "enabled.setter"] - - def __init__( - self, - parent=None, - items: list[tuple[str, type]] | None = None, - form_item_specs: list[FormItemSpec] | None = None, - enabled: bool = True, - pretty_display: bool = False, - client=None, - **kwargs, - ): - """Widget with a list of form items based on a list of types. - - Args: - items (list[tuple[str, type]]): list of tuples of a name for the field and its type. - Should be a type supported by the logic in items.py - form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items. - only one of items or form_item_specs should be - supplied. - enabled (bool, optional): whether fields are enabled for editing. - pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display. - """ - if items is not None and form_item_specs is not None: - logger.error( - "Must specify one and only one of items and form_item_specs! Ignoring `items`." - ) - items = None - if items is None and form_item_specs is None: - logger.error("Must specify one and only one of items and form_item_specs!") - items = [] - super().__init__(parent=parent, client=client, **kwargs) - self._items = form_item_specs or [ - FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display) - for name, item_type in items # type: ignore - ] - self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum) - self._layout = QVBoxLayout() - self._layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(self._layout) - - self._enabled: bool = enabled - - self._form_grid_container = QWidget(parent=self) - self._form_grid_container.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum) - self._form_grid_container.setLayout(QVBoxLayout()) - self._layout.addWidget(self._form_grid_container) - - self._form_grid = QWidget(parent=self._form_grid_container) - self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum) - self._form_grid.setLayout(self._new_grid_layout()) - - self._widget_types: dict | None = None - self._widget_from_type = widget_from_type - self._post_init() - - def _post_init(self): - """Override this if a subclass should do things after super().__init__ and before populate()""" - self.populate() - self.enabled = self._enabled # type: ignore # QProperty - - def populate(self): - self._clear_grid() - for r, item in enumerate(self._items): - self._add_griditem(item, r) - gl: QGridLayout = self._form_grid.layout() - gl.setRowStretch(gl.rowCount(), 1) - - def _add_griditem(self, item: FormItemSpec, row: int): - grid = self._form_grid.layout() - # Use title from FieldInfo if available, otherwise use the property name - label_text = item.info.title if item.info.title else item.name - label = QLabel(parent=self._form_grid, text=label_text) - label.setProperty("_model_field_name", item.name) - label.setToolTip(item.info.description or item.name) - grid.addWidget(label, row, 0) - widget = self._widget_from_type(item, self._widget_types)(parent=self._form_grid, spec=item) - widget.valueChanged.connect(self.value_changed) - widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum) - grid.addWidget(widget, row, 1) - - def enumerate_form_widgets(self): - """Return a generator over the rows of the form, with the row number, the label widget (to - which the field name is attached as a property "_model_field_name"), and the entry widget""" - grid: QGridLayout = self._form_grid.layout() # type: ignore - for i in range(grid.rowCount() - 1): # One extra row for stretch - label, widget = grid.itemAtPosition(i, 0), grid.itemAtPosition(i, 1) - if label is None or widget is None: # e.g. the error line under a field - continue - yield GridRow(i, label.widget(), widget.widget()) - - def _dict_from_grid(self) -> dict[str, DynamicFormItemType]: - return { - row.label.property("_model_field_name"): row.widget.getValue() - for row in self.enumerate_form_widgets() - } - - def _clear_grid(self): - gl = self._form_grid.layout() - while w := gl.takeAt(0): - w = w.widget() - if hasattr(w, "teardown"): - w.teardown() - w.deleteLater() - self._form_grid_container.layout().removeWidget(self._form_grid) - self._form_grid.deleteLater() - self._form_grid = QWidget() - self._form_grid.setLayout(self._new_grid_layout()) - self._form_grid_container.layout().addWidget(self._form_grid) - self.update_size() - - def update_size(self): - self._form_grid.adjustSize() - self._form_grid_container.adjustSize() - self.adjustSize() - - def _new_grid_layout(self): - new_grid = QGridLayout() - new_grid.setContentsMargins(0, 0, 0, 0) - return new_grid - - @property - def widget_dict(self): - return { - row.label.property("_model_field_name"): row.widget - for row in self.enumerate_form_widgets() - } - - @SafeProperty(bool) - def enabled(self): - return self._enabled - - @enabled.setter - def enabled(self, value: bool): - self._enabled = value - self.setEnabled(value) - - -class PydanticModelForm(TypedForm): - form_data_updated = Signal(dict) - form_data_cleared = Signal(NoneType) - validity_proc = Signal(bool) - - def __init__( - self, - parent=None, - data_model: type[BaseModel] | None = None, - enabled: bool = True, - pretty_display: bool = False, - client=None, - **kwargs, - ): - """ - A form generated from a pydantic model. - - Args: - data_model (type[BaseModel]): the model class for which to generate a form. - enabled (bool, optional): whether fields are enabled for editing. - pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display. - - """ - self._pretty_display = pretty_display - self._md_schema = data_model - self._field_errors: dict[str, QLabel] = {} - super().__init__( - parent=parent, - form_item_specs=self._form_item_specs(), - enabled=enabled, - client=client, - **kwargs, - ) - - self._validity_message = QLabel("Not yet validated", self) - self._validity_message.setWordWrap(True) - self._validity_message.setTextFormat(Qt.TextFormat.PlainText) - self._validity = self._validity_message - self._validity_state: bool | None = None - self._validity_summary = "Not yet validated" - self._validity_issues: list[ValidationIssue] = [] - self._layout.addWidget(self._validity) - self.value_changed.connect(self.validate_form) - - self._connect_pretty_display_theme() - - @SafeSlot() - def clear(self): ... - - def set_pretty_display_theme(self, theme: str = "dark"): - if self._pretty_display: - self.setStyleSheet(styles.pretty_display_theme(theme)) - - def _connect_pretty_display_theme(self): - """Connect the pretty-display styling to the theme-updated signal.""" - qapp = QApplication.instance() - if hasattr(qapp, "theme_signal"): - qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore - - def set_schema(self, schema: type[BaseModel]): - self._md_schema = schema - self.populate() - - def set_data(self, data: BaseModel): - """Fill the data for the form. - - Args: - data (BaseModel): the data to enter into the form. Must be the same type as the - currently set schema, raises TypeError otherwise.""" - if not self._md_schema: - raise ValueError("Schema not set - can't set data") - if not isinstance(data, self._md_schema): - raise TypeError(f"Supplied data {data} not of type {self._md_schema}") - for form_item in self.enumerate_form_widgets(): - form_item.widget.setValue(getattr(data, form_item.label.property("_model_field_name"))) - - def _form_item_specs(self): - return [ - FormItemSpec( - name=name, info=info, item_type=info.annotation, pretty_display=self._pretty_display - ) - for name, info in self._md_schema.model_fields.items() - ] - - def update_items_from_schema(self): - self._items = self._form_item_specs() - - def populate(self): - self.update_items_from_schema() - self._field_errors = {} - super().populate() - - def _add_griditem(self, item: FormItemSpec, row: int): - # every field takes two grid rows: the field itself and its (hidden) error line below - super()._add_griditem(item, 2 * row) - error = QLabel(parent=self._form_grid) - error.setWordWrap(True) - error.setVisible(False) - self._form_grid.layout().addWidget(error, 2 * row + 1, 1) - self._field_errors[item.name] = error - - def get_form_data(self): - """Get the entered metadata as a dict.""" - return self._dict_from_grid() - - def _set_validity(self, valid: bool, message: str, details: str = ""): - """Show a short validation result under the form. - - Args: - valid (bool): whether the form data is valid. - message (str): the one-line summary. - details (str): readable list of the problems, used as tooltip. - """ - self._validity_state = valid - self._validity_summary = message - accents = get_accent_colors() - color = accents.success if valid else accents.emergency - self._validity_message.setStyleSheet(f"color: {color.name()};") - self._validity_message.setText(message) - self._validity_message.setToolTip(details) - - def _show_field_errors(self, issues: list[ValidationIssue]): - """Show each field's problem directly under its input; hide the lines of valid fields.""" - problems = {issue.field: issue.problem for issue in issues} - color = get_accent_colors().emergency.name() - for name, label in self._field_errors.items(): - problem = problems.get(name) - label.setVisible(problem is not None) - if problem is not None: - label.setStyleSheet(f"color: {color};") - label.setText(problem) - - def _describe_errors(self, error: ValidationError) -> list[ValidationIssue]: - """One issue per invalid top-level field, without pydantic's error codes and links. - - Errors inside a field (list entries, dict keys, nested models) keep their location, e.g. - ``[1]: Input should be a valid integer``; every failing entry of a field is listed.""" - problems: dict[str, list[str]] = {} - entered: dict[str, str] = {} - for err in error.errors(): - loc = tuple(err["loc"]) - name = str(loc[0]) if loc else "form" - path = self._error_path(name, loc[1:]) - value = err.get("input") - if err["type"] == "missing" or value is None: - message = "Required - please fill in" - else: - message = err["msg"] - line = f"{path}: {message}" if path else message - lines = problems.setdefault(name, []) - if line not in lines: - lines.append(line) - if name not in entered: - if value is None: - entered[name] = "empty" - else: - entered[name] = repr(value) if isinstance(value, str) else str(value) - issues = [] - for name, lines in problems.items(): - field = self._md_schema.model_fields.get(name) - title = field.title if field is not None and field.title else name - issues.append(ValidationIssue(name, title, "\n".join(lines), entered[name])) - return issues - - def _error_path(self, name: str, sub_loc: tuple) -> str: - """Readable location inside a field, e.g. ``[1]``, ``key`` or ``inner[0].name``. - - For fields with several possible types (``int | float``), pydantic reports one error per - type, tagged with the type name; that tag is not a location and is dropped.""" - field = self._md_schema.model_fields.get(name) - annotation = field.annotation if field is not None else None - if get_origin(annotation) in (Union, UnionType): - members = [arg for arg in get_args(annotation) if arg is not NoneType] - if len(members) > 1 and sub_loc and isinstance(sub_loc[0], str): - sub_loc = sub_loc[1:] - path = "" - for part in sub_loc: - path += f"[{part}]" if isinstance(part, int) else f".{part}" - return path.lstrip(".") - - @staticmethod - def _summarize_issues(issues: list[ValidationIssue]) -> str: - if len(issues) == 1: - return f"Invalid field: {issues[0].title}" - return f"{len(issues)} invalid fields: {', '.join(issue.title for issue in issues)}" - - def _summarize_errors(self, error: ValidationError) -> str: - """One short line naming the invalid fields, so the form keeps its size.""" - return self._summarize_issues(self._describe_errors(error)) - - def validate_form(self, *_) -> bool: - """validate the currently entered metadata against the pydantic schema. - If successful, returns on metadata_emitted and returns true. - Otherwise, emits on form_data_cleared and returns false.""" - try: - metadata_dict = self.get_form_data() - self._md_schema.model_validate(metadata_dict) - self._validity_issues = [] - self._show_field_errors([]) - self._set_validity(True, "No errors!") - self.form_data_updated.emit(metadata_dict) - self.validity_proc.emit(True) - return True - except ValidationError as e: - self._validity_issues = self._describe_errors(e) - self._show_field_errors(self._validity_issues) - details = "\n".join( - f"{issue.title}: {line}" - for issue in self._validity_issues - for line in issue.problem.split("\n") - ) - self._set_validity(False, self._summarize_issues(self._validity_issues), details) - self.form_data_cleared.emit(None) - self.validity_proc.emit(False) - return False - - -class PydanticModelFormItem(DynamicFormItem): - def __init__( - self, parent: QWidget | None = None, *, spec: FormItemSpec, model: type[BaseModel] - ) -> None: - self._data_model = model - - super().__init__(parent=parent, spec=spec) - self._main_widget.form_data_updated.connect(self._value_changed) - - def _add_main_widget(self) -> None: - - self._main_widget = PydanticModelForm(data_model=self._data_model) - self._layout.addWidget(self._main_widget) - - def getValue(self): - return self._main_widget.get_form_data() - - def setValue(self, value: dict): - self._main_widget.set_data(self._data_model.model_validate(value)) diff --git a/bec_widgets/utils/forms_from_types/items.py b/bec_widgets/utils/forms_from_types/items.py deleted file mode 100644 index b4f0fb4b..00000000 --- a/bec_widgets/utils/forms_from_types/items.py +++ /dev/null @@ -1,701 +0,0 @@ -from __future__ import annotations - -import inspect -import typing -from abc import abstractmethod -from decimal import Decimal -from types import GenericAlias, NoneType, UnionType -from typing import ( - Any, - Callable, - Final, - Generic, - Iterable, - Literal, - NamedTuple, - Optional, - OrderedDict, - Protocol, - TypeVar, - get_args, - runtime_checkable, -) - -from bec_lib.logger import bec_logger -from bec_qthemes import material_icon -from pydantic import BaseModel, ConfigDict, Field, field_validator -from pydantic.fields import FieldInfo -from pydantic_core import PydanticUndefined -from qtpy import QtCore -from qtpy.QtCore import QSize, Qt, Signal # type: ignore -from qtpy.QtGui import QFontMetrics -from qtpy.QtWidgets import ( - QApplication, - QButtonGroup, - QCheckBox, - QComboBox, - QDoubleSpinBox, - QGridLayout, - QHBoxLayout, - QLabel, - QLayout, - QLineEdit, - QListWidget, - QListWidgetItem, - QPushButton, - QRadioButton, - QSizePolicy, - QSpinBox, - QToolButton, - QVBoxLayout, - QWidget, -) - -from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.utils.widget_io import WidgetIO -from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable -from bec_widgets.widgets.editors.scan_metadata._util import ( - clearable_required, - field_default, - field_limits, - field_maxlen, - field_minlen, - field_precision, -) -from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch - -logger = bec_logger.logger - - -class FormItemSpec(BaseModel): - """ - The specification for an item in a dynamically generated form. Uses a pydantic FieldInfo - to store most annotation info, since one of the main purposes is to store data for - forms generated from pydantic models, but can also be composed from other sources or by hand. - """ - - model_config = ConfigDict(arbitrary_types_allowed=True) - - item_type: type | UnionType | GenericAlias | Optional[Any] - name: str - info: FieldInfo = FieldInfo() - pretty_display: bool = Field( - default=False, - description="Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.", - ) - - @field_validator("item_type", mode="before") - @classmethod - def _validate_type(cls, v): - allowed_primitives = [str, int, float, bool] - if isinstance(v, (type, UnionType)): - return v - if isinstance(v, GenericAlias): - if v.__origin__ in [list, dict, set] and all( - arg in allowed_primitives for arg in v.__args__ - ): - return v - raise ValueError( - f"Generics of type {v} are not supported - only lists, dicts and sets of primitive types {allowed_primitives}" - ) - if type(v) is type(Literal[""]): # _LiteralGenericAlias is not exported from typing - arg_types = set(type(arg) for arg in v.__args__) - if len(arg_types) != 1: - raise ValueError("Mixtures of literal types are not supported!") - if (t := arg_types.pop()) in allowed_primitives: - return t - raise ValueError(f"Literals of type {t} are not supported") - - -class ClearableBoolEntry(QWidget): - stateChanged = Signal() - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self._layout = QHBoxLayout() - self._layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(self._layout) - self._layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize) - self._entry = QButtonGroup() - self._true = QRadioButton("true", parent=self) - self._false = QRadioButton("false", parent=self) - for button in [self._true, self._false]: - self._layout.addWidget(button) - self._entry.addButton(button) - button.toggled.connect(self.stateChanged) - - def clear(self): - self._entry.setExclusive(False) - self._true.setChecked(False) - self._false.setChecked(False) - self._entry.setExclusive(True) - - def isChecked(self) -> bool | None: - if not self._true.isChecked() and not self._false.isChecked(): - return None - return self._true.isChecked() - - def setChecked(self, value: bool | None): - if value is None: - self.clear() - elif value: - self._true.setChecked(True) - self._false.setChecked(False) - else: - self._true.setChecked(False) - self._false.setChecked(True) - - def setToolTip(self, tooltip: str): - self._true.setToolTip(tooltip) - self._false.setToolTip(tooltip) - - -DynamicFormItemType = str | int | float | Decimal | bool | dict | list | None - - -class DynamicFormItem(QWidget): - valueChanged = Signal() - - def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None: - """ - Initializes the form item widget. - - Args: - parent (QWidget | None, optional): The parent widget. Defaults to None. - spec (FormItemSpec): The specification for the form item. - """ - super().__init__(parent) - self._spec = spec - self._layout = QHBoxLayout() - self._layout.setContentsMargins(0, 0, 0, 0) - self._layout.setSizeConstraint(QLayout.SizeConstraint.SetMaximumSize) - self._default = field_default(self._spec.info) - self._desc = self._spec.info.description - self.setLayout(self._layout) - self._add_main_widget() - # Sadly, QWidget and ABC are not compatible - assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore - self._main_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) - self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) - if not spec.pretty_display: - if clearable_required(spec.info): - self._add_clear_button() - else: - self._set_pretty_display() - - @abstractmethod - def getValue(self) -> DynamicFormItemType: ... - - @abstractmethod - def setValue(self, value): ... - - @abstractmethod - def _add_main_widget(self) -> None: - self._main_widget: QWidget - """Add the main data entry widget to self._main_widget and apply any - constraints from the field info""" - - @SafeSlot() - def clear(self, *_): - return - - def _set_pretty_display(self): - self.setEnabled(False) - if button := getattr(self, "_clear_button", None): - button.setVisible(False) - - def _describe(self, pad=" "): - return pad + (self._desc if self._desc else "") - - def _add_clear_button(self): - self._clear_button = QToolButton() - self._clear_button.setIcon( - material_icon(icon_name="close", size=(10, 10), convert_to_pixmap=False) - ) - self._layout.addWidget(self._clear_button) - # the widget added in _add_main_widget must implement .clear() if value is not required - self._clear_button.setToolTip("Clear value or reset to default.") - self._clear_button.clicked.connect(self.clear) # type: ignore - - def _value_changed(self, *_, **__): - self.valueChanged.emit() - - def teardown(self): - self._layout.deleteLater() - self._layout.removeWidget(self._main_widget) - self._main_widget.deleteLater() - self._main_widget = None - - -class StrFormItem(DynamicFormItem): - def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None: - super().__init__(parent=parent, spec=spec) - self._main_widget.textChanged.connect(self._value_changed) - if spec.info.description: - self._main_widget.setPlaceholderText(spec.info.description) - - def _add_main_widget(self) -> None: - self._main_widget = QLineEdit() - self._layout.addWidget(self._main_widget) - min_length, max_length = (field_minlen(self._spec.info), field_maxlen(self._spec.info)) - if max_length: - self._main_widget.setMaxLength(max_length) - self._main_widget.setToolTip( - f"(length min: {min_length} max: {max_length}){self._describe()}" - ) - if self._default: - self._main_widget.setText(self._default) - self._add_clear_button() - - def getValue(self): - if self._main_widget.text() == "": - return self._default - return self._main_widget.text() - - def setValue(self, value: str): - if value is None: - return self._main_widget.setText("") - self._main_widget.setText(str(value)) - - -class IntFormItem(DynamicFormItem): - def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None: - super().__init__(parent=parent, spec=spec) - self._main_widget.textChanged.connect(self._value_changed) - - def _add_main_widget(self) -> None: - self._main_widget = QSpinBox() - self._layout.addWidget(self._main_widget) - min_, max_ = field_limits(self._spec.info, int) - self._main_widget.setMinimum(min_) - self._main_widget.setMaximum(max_) - self._main_widget.setToolTip(f"(range {min_} to {max_}){self._describe()}") - if self._default is not None: - self._main_widget.setValue(self._default) - self._add_clear_button() - else: - self._main_widget.clear() - - def getValue(self): - if self._main_widget.text() == "": - return self._default - return self._main_widget.value() - - def setValue(self, value: int): - if value is None: - self._main_widget.clear() - self._main_widget.setValue(value) - - -class FloatDecimalFormItem(DynamicFormItem): - def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None: - super().__init__(parent=parent, spec=spec) - self._main_widget.textChanged.connect(self._value_changed) - - def _add_main_widget(self) -> None: - precision = field_precision(self._spec.info) - self._main_widget = QDoubleSpinBox() - self._layout.addWidget(self._main_widget) - min_, max_ = field_limits(self._spec.info, float, precision) - self._main_widget.setMinimum(min_) - self._main_widget.setMaximum(max_) - if precision: - self._main_widget.setDecimals(precision) - minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}" - maxstr = f"{float(max_):.3f}" if abs(max_) <= 1000 else f"{float(max_):.3e}" - self._main_widget.setToolTip(f"(range {minstr} to {maxstr}){self._describe()}") - if self._default is not None: - self._main_widget.setValue(self._default) - self._add_clear_button() - else: - self._main_widget.clear() - - def getValue(self): - if self._main_widget.text() == "": - return self._default - return self._main_widget.value() - - def setValue(self, value: float | Decimal): - if value is None: - self._main_widget.clear() - self._main_widget.setValue(float(value)) - - -class BoolFormItem(DynamicFormItem): - def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None: - super().__init__(parent=parent, spec=spec) - self._main_widget.stateChanged.connect(self._value_changed) - - def _add_main_widget(self) -> None: - if clearable_required(self._spec.info): - self._main_widget = ClearableBoolEntry() - else: - self._main_widget = QCheckBox() - self._layout.addWidget(self._main_widget) - self._main_widget.setToolTip(self._describe("")) - self._main_widget.setChecked(self._default) # type: ignore # if there is no default then it will be ClearableBoolEntry and can be set with None - - def getValue(self): - return self._main_widget.isChecked() - - def setValue(self, value): - self._main_widget.setChecked(value) - - -class BoolToggleFormItem(BoolFormItem): - def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None: - if spec.info.default is PydanticUndefined: - spec.info.default = False - super().__init__(parent=parent, spec=spec) - - def _add_main_widget(self) -> None: - self._main_widget = ToggleSwitch() - self._layout.addWidget(self._main_widget) - self._main_widget.setToolTip(self._describe("")) - if self._default is not None: - self._main_widget.setChecked(self._default) - - -class DictFormItem(DynamicFormItem): - def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None: - super().__init__(parent=parent, spec=spec) - self._main_widget.data_changed.connect(self._value_changed) - if spec.info.default is not PydanticUndefined: - self._main_widget.set_default(spec.info.default) - - def _set_pretty_display(self): - self._main_widget.set_button_visibility(False) - super()._set_pretty_display() - - def _add_main_widget(self) -> None: - self._main_widget = DictBackedTable(self, []) - self._layout.addWidget(self._main_widget) - self._main_widget.setToolTip(self._describe("")) - - def getValue(self): - return self._main_widget.dump_dict() - - def setValue(self, value): - self._main_widget.replace_data(value) - - -_IW = TypeVar("_IW", bound=int | float | str) - - -class _ItemAndWidgetType(NamedTuple, Generic[_IW]): - item: type[_IW] - widget: type[QWidget] - default: _IW - - -class ListFormItem(DynamicFormItem): - def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None: - if spec.info.annotation is list: - self._types = _ItemAndWidgetType(str, QLineEdit, "") - elif isinstance(spec.info.annotation, GenericAlias): - args = set(typing.get_args(spec.info.annotation)) - if args == {str}: - self._types = _ItemAndWidgetType(str, QLineEdit, "") - if args == {int}: - self._types = _ItemAndWidgetType(int, QSpinBox, 0) - if args == {float} or args == {int, float}: - self._types = _ItemAndWidgetType(float, QDoubleSpinBox, 0.0) - else: - self._types = _ItemAndWidgetType(str, QLineEdit, "") - super().__init__(parent=parent, spec=spec) - self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) - self._main_widget: QListWidget - self._data = [] - self._min_lines = 2 if spec.pretty_display else 4 - self._repop(self._data) - - def sizeHint(self): - default = super().sizeHint() - return QSize(default.width(), QFontMetrics(self.font()).height() * 4) - - def _add_main_widget(self) -> None: - self._main_widget = QListWidget() - self._layout.addWidget(self._main_widget) - self._layout.setAlignment(Qt.AlignmentFlag.AlignTop) - self._add_buttons() - - def _add_buttons(self): - self._button_holder = QWidget() - self._button_holder.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) - self._buttons = QVBoxLayout() - self._buttons.setContentsMargins(0, 0, 0, 0) - self._button_holder.setLayout(self._buttons) - self._layout.addWidget(self._button_holder) - - self._add_remove_button_holder = QWidget() - self._add_remove_button_layout = QHBoxLayout() - self._add_remove_button_layout.setContentsMargins(0, 0, 0, 0) - self._add_remove_button_holder.setLayout(self._add_remove_button_layout) - - self._add_button = QPushButton("+") - self._add_button.setMinimumHeight(15) - self._add_button.setToolTip("add a new row") - self._remove_button = QPushButton("-") - self._remove_button.setMinimumHeight(15) - self._remove_button.setToolTip("delete the focused row (if any)") - self._add_button.clicked.connect(self._add_row) - self._remove_button.clicked.connect(self._delete_row) - - self._buttons.addWidget(self._add_remove_button_holder) - self._add_remove_button_layout.addWidget(self._add_button) - self._add_remove_button_layout.addWidget(self._remove_button) - - def _set_pretty_display(self): - super()._set_pretty_display() - self._button_holder.setHidden(True) - - def _repop(self, data): - self._main_widget.clear() - for val in data: - self._add_list_item(val) - self.scale_to_data() - - def _add_data_item(self, val=None): - val = val or self._types.default - self._data.append(val) - self._add_list_item(val) - self._repop(self._data) - - def _item_height(self): - return int(QFontMetrics(self.font()).height() * 1.5) - - def _add_list_item(self, val): - item = QListWidgetItem(self._main_widget) - item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable) - item_widget = self._types.widget(parent=self) - item_widget.setMinimumHeight(self._item_height()) - self._main_widget.setGridSize(QSize(0, self._item_height())) - if (layout := item_widget.layout()) is not None: - layout.setContentsMargins(0, 0, 0, 0) - WidgetIO.set_value(item_widget, val) - self._main_widget.setItemWidget(item, item_widget) - self._main_widget.addItem(item) - WidgetIO.connect_widget_change_signal(item_widget, self._update) - return item_widget - - def _update(self, _, value, *args): - self._data[self._main_widget.currentRow()] = value - - @SafeSlot() - def _add_row(self): - self._add_data_item(self._types.default) - self._repop(self._data) - - @SafeSlot() - def _delete_row(self): - if selected := self._main_widget.currentItem(): - self._main_widget.removeItemWidget(selected) - row = self._main_widget.currentRow() - self._main_widget.takeItem(row) - self._data.pop(row) - self._repop(self._data) - - @SafeSlot() - def clear(self): - self._repop([]) - - def getValue(self): - return self._data - - def setValue(self, value: Iterable): - if set(map(type, value)) | {self._types.item} != {self._types.item}: - raise ValueError(f"This widget only accepts items of type {self._types.item}") - self._data = list(value) - self._repop(self._data) - - def set_max_height_in_lines(self, lines: int): - outer_inc = 1 if self._spec.pretty_display else 3 - self._main_widget.setFixedHeight(self._item_height() * max(lines, self._min_lines)) - self._button_holder.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + 1)) - self.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + outer_inc)) - - def scale_to_data(self, *_): - self.set_max_height_in_lines(self._main_widget.count() + 1) - - -class SetFormItem(ListFormItem): - def _add_main_widget(self) -> None: - super()._add_main_widget() - self._add_item_field = self._types.widget() - self._buttons.addWidget(QLabel("Add new:")) - self._buttons.addWidget(self._add_item_field) - self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Minimum) - - @SafeSlot() - def _add_row(self): - self._add_data_item(WidgetIO.get_value(self._add_item_field)) - self._repop(self._data) - - def _update(self, _, value, *args): - if value in self._data: - return - return super()._update(_, value, *args) - - def _add_data_item(self, val=None): - val = val or self._types.default - if val == self._types.default or val in self._data: - return - self._data.append(val) - self._add_list_item(val) - - def _add_list_item(self, val): - item_widget = super()._add_list_item(val) - if isinstance(item_widget, QLineEdit): - item_widget.setReadOnly(True) - return item_widget - - def getValue(self): - return set(self._data) - - def setValue(self, value: set): - return super().setValue(set(value)) - - -class StrLiteralFormItem(DynamicFormItem): - def _add_main_widget(self) -> None: - self._main_widget = QComboBox() - self._options = get_args(self._spec.info.annotation) - for opt in self._options: - self._main_widget.addItem(opt) - self._layout.addWidget(self._main_widget) - - def getValue(self): - if self._main_widget.currentIndex() == -1: - return None - return self._main_widget.currentText() - - def setValue(self, value: str | None): - if value is None: - self.clear() - return - for i in range(self._main_widget.count()): - if self._main_widget.itemText(i) == value: - self._main_widget.setCurrentIndex(i) - return - raise ValueError(f"Cannot set value: {value}, options are: {self._options}") - - def clear(self): - self._main_widget.setCurrentIndex(-1) - - -class OptionalStrLiteralFormItem(StrLiteralFormItem): - def _add_main_widget(self) -> None: - self._main_widget = QComboBox() - self._options = get_args(get_args(self._spec.info.annotation)[0]) - for opt in self._options: - self._main_widget.addItem(opt) - self._layout.addWidget(self._main_widget) - - -WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]] - - -@runtime_checkable -class _ItemTypeFn(Protocol): - def __call__(self, spec: FormItemSpec) -> type[DynamicFormItem]: ... - - -WidgetTypeRegistry = OrderedDict[ - str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem] | _ItemTypeFn] -] - - -def _is_string_literal(t: type): - return type(t) is type(Literal[""]) and set(type(arg) for arg in get_args(t)) == {str} - - -def _is_optional_string_literal(t: type): - if not hasattr(t, "__args__"): - return False - if len(t.__args__) != 2: - return False - if _is_string_literal(t.__args__[0]) and t.__args__[1] is NoneType: - return True - return False - - -DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | { - # dict literals are ordered already but TypedForm subclasses may modify coppies of this dict - # and delete/insert keys or change the order - "literal_str": (lambda spec: _is_string_literal(spec.info.annotation), StrLiteralFormItem), - "optional_literal_str": ( - lambda spec: _is_optional_string_literal(spec.info.annotation), - OptionalStrLiteralFormItem, - ), - "str": (lambda spec: spec.item_type in [str, str | None, None], StrFormItem), - "int": (lambda spec: spec.item_type in [int, int | None], IntFormItem), - "float_decimal": ( - lambda spec: spec.item_type in [float, float | None, Decimal, Decimal | None], - FloatDecimalFormItem, - ), - "bool": (lambda spec: spec.item_type in [bool, bool | None], BoolFormItem), - "dict": ( - lambda spec: spec.item_type in [dict, dict | None] - or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is dict), - DictFormItem, - ), - "list": ( - lambda spec: spec.item_type in [list, list | None] - or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is list), - ListFormItem, - ), - "set": ( - lambda spec: spec.item_type in [set, set | None] - or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is set), - SetFormItem, - ), -} - - -def widget_from_type( - spec: FormItemSpec, widget_types: WidgetTypeRegistry | None = None -) -> type[DynamicFormItem]: - widget_types = widget_types or DEFAULT_WIDGET_TYPES - for predicate, widget_type in widget_types.values(): - if predicate(spec): - if inspect.isclass(widget_type) and issubclass(widget_type, DynamicFormItem): - return widget_type - return widget_type(spec) - - logger.warning( - f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation." - ) - return StrFormItem - - -if __name__ == "__main__": # pragma: no cover - - class TestModel(BaseModel): - value0: set = Field(set(["a", "b"])) - value1: str | None = Field(None) - value2: bool | None = Field(None) - value3: bool = Field(True) - value4: int = Field(123) - value5: int | None = Field() - value6: list[int] = Field() - value7: list = Field() - literal: Literal["a", "b", "c"] - nullable_literal: Literal["a", "b", "c"] | None = None - - app = QApplication([]) - w = QWidget() - layout = QGridLayout() - w.setLayout(layout) - items = [] - for i, (field_name, info) in enumerate(TestModel.model_fields.items()): - spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info) - layout.addWidget(QLabel(field_name), i, 0) - widg = widget_from_type(spec)(spec=spec) - items.append(widg) - layout.addWidget(widg, i, 1) - - items[6].setValue([1, 2, 3, 4]) - items[7].setValue(["1", "2", "asdfg", "qwerty"]) - - w.show() - app.exec() diff --git a/bec_widgets/utils/forms_from_types/styles.py b/bec_widgets/utils/forms_from_types/styles.py deleted file mode 100644 index 61ab48f8..00000000 --- a/bec_widgets/utils/forms_from_types/styles.py +++ /dev/null @@ -1,28 +0,0 @@ -from bec_qthemes.qss_editor.qss_editor import ( - THEMES_PATH, - build_palette_from_mapping, - read_theme_xml, -) - - -def pretty_display_theme(theme: str = "dark"): - _, mapping = read_theme_xml(THEMES_PATH / f"{theme}.xml") - palette = build_palette_from_mapping(mapping) - foreground = palette.text().color().name() - background = palette.base().color().name() - border = palette.shadow().color().name() - # palette.highlight() rather than accent(): on Qt 6.10+ accent() returns the - # platform accent, and bec_qthemes palettes only set Highlight anyway. - accent = palette.highlight().color().name() - return f""" -QWidget {{color: {foreground}; background-color: {background}}} -QLabel {{ font-weight: bold; }} -QLineEdit,QLabel,QTreeView {{ border-style: solid; border-width: 2px; border-color: {border} }} -QRadioButton {{ color: {foreground}; }} -QRadioButton::indicator::checked {{ color: {accent}; }} -QCheckBox {{ color: {accent}; }} -""" - - -if __name__ == "__main__": - print(pretty_display_theme()) diff --git a/bec_widgets/widgets/editors/dict_backed_table.py b/bec_widgets/widgets/editors/dict_backed_table.py deleted file mode 100644 index 248b0b5d..00000000 --- a/bec_widgets/widgets/editors/dict_backed_table.py +++ /dev/null @@ -1,281 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from qtpy import QtWidgets -from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore -from qtpy.QtGui import QFontMetrics -from qtpy.QtWidgets import ( - QApplication, - QHBoxLayout, - QPushButton, - QSizePolicy, - QTreeView, - QVBoxLayout, - QWidget, -) - -from bec_widgets.utils.error_popups import SafeProperty, SafeSlot - -_NOT_SET = object() - - -class DictBackedTableModel(QAbstractTableModel): - def __init__(self, data): - """A model to go with DictBackedTable, which represents key-value pairs - to be displayed in a TreeWidget. - - Args: - data (list[list[str]]): list of key-value pairs to initialise with""" - super().__init__() - # copy, so that rows are never added or removed behind the model's back - self._data: list[list[str]] = [list(row) for row in data] - self._default = _NOT_SET - self._disallowed_keys: list[str] = [] - - # pylint: disable=missing-function-docstring - # see QAbstractTableModel documentation for these methods - - def headerData( - self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole() - ) -> Any: - if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: - return "Key" if section == 0 else "Value" - return super().headerData(section, orientation, role) - - def rowCount(self, index: QModelIndex = QModelIndex()): - return 0 if index.isValid() else len(self._data) - - def columnCount(self, index: QModelIndex = QModelIndex()): - return 0 if index.isValid() else 2 - - def data(self, index, role=Qt.ItemDataRole): - if index.isValid(): - if role in [ - Qt.ItemDataRole.DisplayRole, - Qt.ItemDataRole.EditRole, - Qt.ItemDataRole.ToolTipRole, - ]: - try: - return str(self._data[index.row()][index.column()]) - except IndexError: - return None - - def setData(self, index, value, role): - if role == Qt.ItemDataRole.EditRole: - # only keys have to be unique and allowed, values are free - if index.column() == 0 and ( - value in self._disallowed_keys or value in self._other_keys(index.row()) - ): - return False - self._data[index.row()][index.column()] = str(value) - self.dataChanged.emit(index, index) - return True - return False - - def replaceData(self, data: dict): - """Replace the whole content of the table. - - Args: - data (dict): key-value pairs to display instead of the current rows.""" - self.beginResetModel() - self._data = [[str(k), str(v)] for k, v in data.items()] - self.endResetModel() - - def update_disallowed_keys(self, keys: list[str]): - """Set the list of keys which may not be used. - - Args: - keys (list[str]): list of keys which are forbidden.""" - self._disallowed_keys = keys - for i, item in enumerate(self._data): - if item[0] in self._disallowed_keys: - self._data[i][0] = "" - self.dataChanged.emit(self.index(i, 0), self.index(i, 1)) - - def _other_keys(self, row: int): - return [r[0] for r in self._data[:row] + self._data[row + 1 :]] - - def flags(self, index): - if not index.isValid(): - return Qt.ItemFlag.NoItemFlags - return Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsEditable - - def insertRows(self, row, number, index=QModelIndex()): - """We only support appending one row at a time for now""" - if index.isValid() or row != self.rowCount() or number != 1: - return False - self.beginInsertRows(QModelIndex(), row, row) - self._data.append(["", ""]) - self.endInsertRows() - return True - - def removeRows(self, row, number, index=QModelIndex()): - """This can only be consecutive, so instead of trying to be clever, only support removing one at a time""" - if index.isValid() or number != 1 or not 0 <= row < self.rowCount(): - return False - self.beginRemoveRows(QModelIndex(), row, row) - del self._data[row] - self.endRemoveRows() - return True - - @SafeSlot() - def add_row(self): - """Append an empty row; views are notified through rowsInserted.""" - self.insertRow(self.rowCount()) - - @SafeSlot(list) - def delete_rows(self, rows: list[int]): - """Remove the given rows; views are notified through rowsRemoved. - - Args: - rows (list[int]): indices of the rows to remove.""" - # delete from the end so indices stay correct - for row in sorted(set(rows), reverse=True): - self.removeRows(row, 1, QModelIndex()) - - def set_default(self, value: dict | None): - self._default = value - - def dump_dict(self): - if self._data in [[], [[]], [["", ""]]]: - if self._default is not _NOT_SET: - return self._default - return {} - return dict(self._data) - - def length(self): - return len(self._data) - - -class DictBackedTable(QWidget): - delete_rows = Signal(list) - data_changed = Signal(dict) - - def __init__( - self, - parent: QWidget | None = None, - initial_data: list[list[str]] = [], - autoscale_to_data: bool = True, - ): - """Widget which uses a DictBackedTableModel to display an editable table - which can be extracted as a dict. - - Args: - initial_data (list[list[str]]): list of key-value pairs to initialise with - """ - super().__init__(parent) - - self._layout = QHBoxLayout() - self.setLayout(self._layout) - self._layout.setContentsMargins(0, 0, 0, 0) - - self._table_model = DictBackedTableModel(initial_data) - self._table_view = QTreeView() - - self._table_view.setModel(self._table_model) - self._min_lines = 3 - self.set_height_in_lines(len(initial_data)) - self._table_view.setSizePolicy( - QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) - ) - self._table_view.setAlternatingRowColors(True) - self._table_view.setUniformRowHeights(True) - self._table_view.setWordWrap(False) - self._table_view.header().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents) - self._table_view.header().setSectionResizeMode(5, QtWidgets.QHeaderView.Stretch) - self._autoscale = False - self.autoscale = autoscale_to_data - self._layout.addWidget(self._table_view) - - self._button_holder = QWidget() - self._buttons = QVBoxLayout() - self._button_holder.setLayout(self._buttons) - self._layout.addWidget(self._button_holder) - self._add_button = QPushButton("+") - self._add_button.setToolTip("add a new row") - self._remove_button = QPushButton("-") - self._remove_button.setToolTip("delete rows containing any selected cells") - self._buttons.addWidget(self._add_button) - self._buttons.addWidget(self._remove_button) - self._add_button.clicked.connect(self._table_model.add_row) - self._remove_button.clicked.connect(self.delete_selected_rows) - self.delete_rows.connect(self._table_model.delete_rows) - - # structural changes do not emit dataChanged, so listen for them separately - self._table_model.dataChanged.connect(self._emit_data_changed) - self._table_model.rowsInserted.connect(self._emit_data_changed) - self._table_model.rowsRemoved.connect(self._emit_data_changed) - self._table_model.modelReset.connect(self._emit_data_changed) - - @SafeSlot() - def _emit_data_changed(self, *_): - """Emit data_changed with the table content after any edit, insertion, removal or reset.""" - self.data_changed.emit(self.dump_dict()) - - def set_default(self, value: dict | None): - self._table_model.set_default(value) - - def set_button_visibility(self, value: bool): - self._button_holder.setVisible(value) - - @SafeSlot() - def clear(self): - self._table_model.replaceData({}) - - def replace_data(self, data: dict | None): - self._table_model.replaceData(data or {}) - - def delete_selected_rows(self): - """Delete rows which are part of the selection model""" - cells: list[QModelIndex] = self._table_view.selectionModel().selectedIndexes() - row_indices = list({r.row() for r in cells}) - if row_indices: - self.delete_rows.emit(row_indices) - - def dump_dict(self): - """Get the current content of the table as a dict""" - return self._table_model.dump_dict() - - def update_disallowed_keys(self, keys: list[str]): - """Set the list of keys which may not be used. - - Args: - keys (list[str]): list of keys which are forbidden.""" - self._table_model.update_disallowed_keys(keys) - - def set_height_in_lines(self, lines: int): - self._table_view.setMaximumHeight( - int(QFontMetrics(self._table_view.font()).height() * max(lines + 2, self._min_lines)) - ) - - @SafeSlot() - @SafeSlot(dict) - def scale_to_data(self, *_): - self.set_height_in_lines(self._table_model.length()) - - @SafeProperty(bool) - def autoscale(self): # type: ignore - return self._autoscale - - @autoscale.setter - def autoscale(self, autoscale: bool): - # connect or disconnect only on a change, so the slot is never connected twice - if autoscale and not self._autoscale: - self.data_changed.connect(self.scale_to_data) - elif self._autoscale and not autoscale: - self.data_changed.disconnect(self.scale_to_data) - self._autoscale = autoscale - if autoscale: - self.scale_to_data() - - -if __name__ == "__main__": # pragma: no cover - from bec_widgets.utils.colors import apply_theme - - app = QApplication([]) - apply_theme("dark") - - window = DictBackedTable(None, [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]]) - window.show() - app.exec() diff --git a/bec_widgets/widgets/editors/scan_metadata/_util.py b/bec_widgets/widgets/editors/scan_metadata/_util.py deleted file mode 100644 index da9b2ddf..00000000 --- a/bec_widgets/widgets/editors/scan_metadata/_util.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -import sys -from decimal import Decimal -from math import copysign, inf, nextafter -from typing import TYPE_CHECKING, TypeVar, get_args - -from annotated_types import Ge, Gt, Le, Lt -from bec_lib.logger import bec_logger -from pydantic_core import PydanticUndefined - -if TYPE_CHECKING: # pragma: no cover - from pydantic.fields import FieldInfo - -logger = bec_logger.logger - - -_MININT = -2147483648 -_MAXINT = 2147483647 -_MINFLOAT = -sys.float_info.max -_MAXFLOAT = sys.float_info.max - -T = TypeVar("T", int, float, Decimal) - - -def field_limits(info: FieldInfo, type_: type[T], prec: int | None = None) -> tuple[T, T]: - def _nextafter(x, y): - return nextafter(x, y) if prec is None else x + (10 ** (-prec)) * (copysign(1, y)) - - _min = _MININT if type_ is int else _MINFLOAT - _max = _MAXINT if type_ is int else _MAXFLOAT - for md in info.metadata: - if isinstance(md, Ge): - _min = type_(md.ge) # type: ignore - if isinstance(md, Gt): - _min = type_(md.gt) + 1 if type_ is int else _nextafter(type_(md.gt), inf) # type: ignore - if isinstance(md, Lt): - _max = type_(md.lt) - 1 if type_ is int else _nextafter(type_(md.lt), -inf) # type: ignore - if isinstance(md, Le): - _max = type_(md.le) # type: ignore - return _min, _max # type: ignore - - -def _get_anno(info: FieldInfo, annotation: str, default): - for md in info.metadata: - if hasattr(md, annotation): - return getattr(md, annotation) - return default - - -def field_precision(info: FieldInfo): - return _get_anno(info, "decimal_places", 307) - - -def field_maxlen(info: FieldInfo): - return _get_anno(info, "max_length", None) - - -def field_minlen(info: FieldInfo): - return _get_anno(info, "min_length", None) - - -def field_default(info: FieldInfo): - if info.default is PydanticUndefined: - return - return info.default - - -def clearable_required(info: FieldInfo): - return type(None) in get_args(info.annotation) or ( - info.is_required() and info.default is PydanticUndefined - ) diff --git a/tests/unit_tests/test_dict_backed_table_model.py b/tests/unit_tests/test_dict_backed_table_model.py deleted file mode 100644 index 6aefcc2d..00000000 --- a/tests/unit_tests/test_dict_backed_table_model.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Regression tests for DictBackedTableModel model notifications (bec_widgets#1319). - -Views and proxies attached to a QAbstractItemModel rely on the model announcing every -structural change at its real position and never emitting dataChanged for rows that do not -exist. Qt's QAbstractItemModelTester is used in Warning mode (never Fatal, which aborts the -process); its failures are collected from the Qt log. -""" - -# pylint: disable=missing-function-docstring -# pylint: disable=redefined-outer-name -# pylint: disable=protected-access - -import pytest -from qtpy.QtCore import SIGNAL, QItemSelectionModel, QSortFilterProxyModel, Qt -from qtpy.QtTest import QAbstractItemModelTester - -from bec_widgets.tests.utils import create_widget -from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable - -INITIAL_DATA = [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]] - - -class NotificationRecorder: - """Record a model's notifications as plain tuples, evaluated at emission time.""" - - def __init__(self, model): - self.model = model - self.inserted: list[tuple[bool, int, int]] = [] - self.removed: list[tuple[bool, int, int]] = [] - self.data_changed: list[tuple[bool, int, bool, int, int]] = [] - self.resets = 0 - model.rowsAboutToBeInserted.connect( - lambda parent, first, last: self.inserted.append((parent.isValid(), first, last)) - ) - model.rowsAboutToBeRemoved.connect( - lambda parent, first, last: self.removed.append((parent.isValid(), first, last)) - ) - model.dataChanged.connect(self._on_data_changed) - model.modelReset.connect(self._on_reset) - - def _on_data_changed(self, top_left, bottom_right, *_): - self.data_changed.append( - ( - top_left.isValid(), - top_left.row(), - bottom_right.isValid(), - bottom_right.row(), - self.model.rowCount(), - ) - ) - - def _on_reset(self): - self.resets += 1 - - def invalid_data_changed(self): - """dataChanged emissions with an invalid index or a row outside the model.""" - return [ - entry - for entry in self.data_changed - if not (entry[0] and entry[2] and 0 <= entry[1] <= entry[3] < entry[4]) - ] - - -def _model_rows(model) -> list[str]: - return [model.index(row, 0).data() for row in range(model.rowCount())] - - -def _tester_failures(qtlog) -> list[str]: - return [record.message for record in qtlog.records if record.message.startswith("FAIL!")] - - -@pytest.fixture -def table(qtbot): - return create_widget(qtbot, DictBackedTable, initial_data=[list(r) for r in INITIAL_DATA]) - - -@pytest.fixture -def recorder(table): - return NotificationRecorder(table._table_model) - - -@pytest.fixture -def emitted(table): - """Dicts emitted by DictBackedTable.data_changed.""" - received: list[dict] = [] - table.data_changed.connect(received.append) - return received - - -@pytest.fixture -def model_tester(table): - tester = QAbstractItemModelTester( - table._table_model, QAbstractItemModelTester.FailureReportingMode.Warning - ) - yield tester - tester.deleteLater() - - -def test_add_row_announces_insert_at_end(table, recorder, emitted, capfd): - table._add_button.click() - - assert table._table_model.rowCount() == 4 - assert recorder.inserted == [(False, 3, 3)] - assert recorder.invalid_data_changed() == [] - # passing 0 as the roles argument of dataChanged makes shiboken print conversion errors - assert "Cannot copy-convert" not in capfd.readouterr().err - assert table.dump_dict() == {"key1": "value1", "key2": "value2", "key3": "value3", "": ""} - assert emitted[-1] == table.dump_dict() - - -def test_delete_rows_does_not_touch_removed_rows(table, recorder, emitted): - selection = table._table_view.selectionModel() - selection.select(table._table_model.index(1, 0), QItemSelectionModel.SelectionFlag.Select) - table.delete_selected_rows() - - assert recorder.removed == [(False, 1, 1)] - assert recorder.data_changed == [] - assert table.dump_dict() == {"key1": "value1", "key3": "value3"} - # listeners must see the table after the removal, not before it - assert emitted[-1] == {"key1": "value1", "key3": "value3"} - - -@pytest.mark.parametrize("new_data", [{"a": "1", "b": "2"}, {}]) -def test_replace_data_announces_structural_change(table, recorder, emitted, new_data): - table.replace_data(new_data) - - assert recorder.invalid_data_changed() == [] - assert recorder.resets == 1 - assert table.dump_dict() == new_data - assert emitted[-1] == new_data - - -def test_proxy_model_stays_in_sync(table): - proxy = QSortFilterProxyModel() - proxy.setSourceModel(table._table_model) - proxy.sort(0, Qt.SortOrder.DescendingOrder) - - table._add_button.click() - assert _model_rows(proxy) == sorted(_model_rows(table._table_model), reverse=True) - - table.replace_data({"a": "1", "b": "2"}) - assert _model_rows(proxy) == ["b", "a"] - - table.clear() - assert _model_rows(proxy) == [] - proxy.setSourceModel(None) - - -def test_model_tester_reports_no_failures(table, model_tester, qtlog): - model = table._table_model - table._add_button.click() - model.setData(model.index(3, 0), "key4", Qt.ItemDataRole.EditRole) - table.update_disallowed_keys(["key2"]) - selection = table._table_view.selectionModel() - selection.select(model.index(0, 0), QItemSelectionModel.SelectionFlag.Select) - selection.select(model.index(2, 0), QItemSelectionModel.SelectionFlag.Select) - table.delete_selected_rows() - table.replace_data({"a": "1", "b": "2"}) - table.clear() - - assert _tester_failures(qtlog) == [] - - -def test_tables_do_not_share_backing_data(qtbot): - first = create_widget(qtbot, DictBackedTable) - second = create_widget(qtbot, DictBackedTable) - first._add_button.click() - - assert first._table_model.rowCount() == 1 - assert second._table_model.rowCount() == 0 - - -def test_values_are_not_checked_against_keys(table): - model = table._table_model - table.update_disallowed_keys(["forbidden"]) - - # a value may repeat another row's key or a disallowed key - assert model.setData(model.index(0, 1), "key2", Qt.ItemDataRole.EditRole) - assert model.setData(model.index(1, 1), "forbidden", Qt.ItemDataRole.EditRole) - # keys still have to be unique and allowed - assert not model.setData(model.index(0, 0), "key2", Qt.ItemDataRole.EditRole) - assert not model.setData(model.index(0, 0), "forbidden", Qt.ItemDataRole.EditRole) - assert table.dump_dict() == {"key1": "key2", "key2": "forbidden", "key3": "value3"} - - -def _scale_receivers(widget) -> int: - return widget.receivers(SIGNAL("data_changed(QVariantMap)")) - - -@pytest.mark.parametrize("autoscale", [True, False]) -def test_autoscale_connects_scale_to_data_once(qtbot, autoscale, recwarn): - table = create_widget(qtbot, DictBackedTable, autoscale_to_data=autoscale) - assert _scale_receivers(table) == int(autoscale) - - table.autoscale = True - table.autoscale = True - assert _scale_receivers(table) == 1 - - table.autoscale = False - table.autoscale = False - assert _scale_receivers(table) == 0 - assert not [w for w in recwarn if "Failed to disconnect" in str(w.message)] diff --git a/tests/unit_tests/test_generated_form_form.py b/tests/unit_tests/test_generated_form_form.py index 236a973d..42671699 100644 --- a/tests/unit_tests/test_generated_form_form.py +++ b/tests/unit_tests/test_generated_form_form.py @@ -10,8 +10,6 @@ from qtpy.QtWidgets import QCheckBox, QComboBox, QDoubleSpinBox, QLabel, QLineEd from bec_widgets.tests.client_mocks import mocked_client from bec_widgets.utils.forms_from_types.entry_list_editor import EntryListEditor -from bec_widgets.utils.forms_from_types.forms import PydanticModelForm, TypedForm -from bec_widgets.utils.forms_from_types.items import FloatDecimalFormItem, IntFormItem, StrFormItem from bec_widgets.utils.forms_from_types.pydantic_widget_form import ( EXTRA_FIELDS_KEY, OptionalValueWidget, @@ -28,20 +26,6 @@ from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox # pylint: disable=protected-access -class ExampleSchema(BaseModel): - str_optional: str | None = Field( - None, title="Optional string", description="an optional string", max_length=23 - ) - str_required: str - bool_optional: bool | None = Field(None) - bool_required_default: bool = Field(True) - bool_required_nodefault: bool = Field() - int_default: int = Field(123) - int_nodefault_optional: int | None = Field(lt=-1, ge=-44) - float_nodefault: float - decimal_dp_limits_nodefault: Decimal = Field(decimal_places=2, gt=1, le=34.5) - - class GeneratedBeamlineSchema(BaseModel): name: str = Field(title="State name", description="Unique state identifier.") title: str | None = Field(default=None, title="Display title", description="Visible title.") @@ -121,56 +105,6 @@ class GeneratedRequiredNumericAndOptionalBoolSchema(BaseModel): scale: float -TEST_DICT = { - "sample_name": "test name", - "str_optional": "None", - "str_required": "something", - "bool_optional": None, - "bool_required_default": True, - "bool_required_nodefault": False, - "int_default": 21, - "int_nodefault_optional": -10, - "float_nodefault": 123.456, - "decimal_dp_limits_nodefault": 34.5, -} - - -@pytest.fixture -def example_md(): - return ExampleSchema.model_validate(TEST_DICT) - - -@pytest.fixture -def model_widget(qtbot): - widget = PydanticModelForm(data_model=ExampleSchema) - widget.populate() - qtbot.addWidget(widget) - yield widget - - -def test_widget_dict(model_widget: PydanticModelForm): - assert isinstance(model_widget.widget_dict["str_optional"], StrFormItem) - assert isinstance(model_widget.widget_dict["float_nodefault"], FloatDecimalFormItem) - assert isinstance(model_widget.widget_dict["int_default"], IntFormItem) - - -def test_widget_set_data(model_widget: PydanticModelForm): - data = ExampleSchema.model_validate(TEST_DICT) - model_widget.set_data(data) - for key in [ - "str_optional", - "str_required", - "bool_optional", - "bool_required_default", - "bool_required_nodefault", - "int_default", - "int_nodefault_optional", - "float_nodefault", - "decimal_dp_limits_nodefault", - ]: - assert model_widget.widget_dict[key].getValue() == TEST_DICT[key] - - def test_pydantic_widget_form_uses_field_metadata_and_type_widgets(qtbot, mocked_client): form = PydanticWidgetForm(GeneratedBeamlineSchema, client=mocked_client) qtbot.addWidget(form) diff --git a/tests/unit_tests/test_generated_form_items.py b/tests/unit_tests/test_generated_form_items.py deleted file mode 100644 index abb7bec4..00000000 --- a/tests/unit_tests/test_generated_form_items.py +++ /dev/null @@ -1,122 +0,0 @@ -import sys -from typing import Any, Literal, get_args - -import pytest -from pydantic import ValidationError -from pydantic.fields import FieldInfo - -from bec_widgets.utils.forms_from_types.items import FormItemSpec, ListFormItem -from bec_widgets.utils.widget_io import WidgetIO - - -@pytest.mark.parametrize( - ["input", "validity"], - [ - ({}, False), - ({"item_type": int, "name": "test", "info": FieldInfo(), "pretty_display": True}, True), - ( - { - "item_type": dict[dict, dict], - "name": "test", - "info": FieldInfo(), - "pretty_display": True, - }, - False, - ), - ( - { - "item_type": dict[str, str], - "name": "test", - "info": FieldInfo(), - "pretty_display": True, - }, - True, - ), - ( - { - "item_type": Literal["a", "b"], - "name": "test", - "info": FieldInfo(), - "pretty_display": True, - }, - True, - ), - ( - { - "item_type": Literal["a", 2], - "name": "test", - "info": FieldInfo(), - "pretty_display": True, - }, - False, - ), - ], -) -def test_form_item_spec(input, validity): - if validity: - assert FormItemSpec.model_validate(input) - else: - with pytest.raises(ValidationError): - FormItemSpec.model_validate(input) - - -@pytest.fixture( - params=[ - {"type": list[int], "value": [1, 2, 3], "extra": 79}, - {"type": list[str], "value": ["a", "b", "c"], "extra": "string"}, - {"type": list[float], "value": [0.1, 0.2, 0.3], "extra": 79.0}, - ] -) -def list_field_and_values(request, qtbot): - itype, vals, extra = ( - request.param.get("type"), - request.param.get("value"), - request.param.get("extra"), - ) - spec = FormItemSpec(item_type=itype, name="test_list", info=FieldInfo(annotation=itype)) - (widget := ListFormItem(parent=None, spec=spec)).setValue(vals) - qtbot.addWidget(widget) - yield widget, vals, extra, get_args(itype)[0] - - -def test_list_metadata_field(list_field_and_values: tuple[ListFormItem, list, Any, type]): - list_field, vals, extra, _ = list_field_and_values - assert list_field.getValue() == vals - assert list_field._main_widget.count() == 3 - - list_field._add_button.click() - assert len(list_field.getValue()) == 4 - assert list_field._main_widget.count() == 4 - - list_field._main_widget.setCurrentRow(-1) - list_field._remove_button.click() - assert len(list_field.getValue()) == 4 - assert list_field._main_widget.count() == 4 - - list_field._main_widget.setCurrentRow(2) - list_field._remove_button.click() - assert list_field.getValue() == vals[:2] + [list_field._types.default] - assert list_field._main_widget.count() == 3 - - list_field._main_widget.setCurrentRow(1) - WidgetIO.set_value(list_field._main_widget.itemWidget(list_field._main_widget.item(1)), extra) - assert list_field._main_widget.count() == 3 - assert list_field.getValue() == [vals[0], extra, list_field._types.default] - - list_field._add_data_item(extra) - assert list_field._main_widget.count() == 4 - assert list_field.getValue() == [vals[0], extra, list_field._types.default, extra] - - -def test_list_field_value_acceptance(list_field_and_values: tuple[ListFormItem, list, Any, type]): - class _WrongType(object): ... - - list_field, _, _, t = list_field_and_values - list_field.setValue([]) - assert list_field._main_widget.count() == 0 - list_field.setValue([t(), t(), t()]) - assert list_field._main_widget.count() == 3 - with pytest.raises(ValueError) as e: - list_field.setValue([_WrongType()]) - assert list_field._main_widget.count() == 3 - assert e.match(f"This widget only accepts items of type {t}") diff --git a/tests/unit_tests/test_scan_metadata.py b/tests/unit_tests/test_scan_metadata.py index 002b6a69..21410b2b 100644 --- a/tests/unit_tests/test_scan_metadata.py +++ b/tests/unit_tests/test_scan_metadata.py @@ -6,13 +6,11 @@ from bec_lib import messages from bec_lib.metadata_schema import BasicScanMetadata from pydantic import Field, ValidationError from pydantic.types import Json -from qtpy.QtCore import QItemSelectionModel, QPoint, Qt from qtpy.QtWidgets import QCheckBox, QDoubleSpinBox, QFormLayout, QLineEdit, QSpinBox from bec_widgets.tests.utils import create_widget from bec_widgets.utils.forms_from_types.entry_list_editor import EntryListEditor from bec_widgets.utils.forms_from_types.pydantic_widget_form import OptionalValueWidget -from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox @@ -324,44 +322,3 @@ def test_scan_metadata_form_drops_deleted_extra_row(qtbot): assert received, "deleting a row must re-validate the form" assert "k1" not in received[-1] assert received[-1]["k2"] == "v2" - - -@pytest.fixture -def table(): - table = DictBackedTable( - initial_data=[["key1", "value1"], ["key2", "value2"], ["key3", "value3"]] - ) - yield table - table._table_model.deleteLater() - table._table_view.deleteLater() - table.deleteLater() - - -def test_additional_metadata_table_add_row(table: DictBackedTable): - assert table._table_model.rowCount() == 3 - table._add_button.click() - assert table._table_model.rowCount() == 4 - - -def test_additional_metadata_table_delete_row(table: DictBackedTable): - assert table._table_model.rowCount() == 3 - m = table._table_view.selectionModel() - item = table._table_view.indexAt(QPoint(0, 0)).siblingAtRow(1) - m.select(item, QItemSelectionModel.SelectionFlag.Select) - table.delete_selected_rows() - assert table._table_model.rowCount() == 2 - assert list(table.dump_dict().keys()) == ["key1", "key3"] - - -def test_additional_metadata_allows_changes(table: DictBackedTable): - assert table._table_model.rowCount() == 3 - assert list(table.dump_dict().keys()) == ["key1", "key2", "key3"] - table._table_model.setData(table._table_model.index(1, 0), "key4", Qt.ItemDataRole.EditRole) - assert list(table.dump_dict().keys()) == ["key1", "key4", "key3"] - - -def test_additional_metadata_doesnt_allow_dupes(table: DictBackedTable): - assert table._table_model.rowCount() == 3 - assert list(table.dump_dict().keys()) == ["key1", "key2", "key3"] - table._table_model.setData(table._table_model.index(1, 0), "key1", Qt.ItemDataRole.EditRole) - assert list(table.dump_dict().keys()) == ["key1", "key2", "key3"]