refactor(forms): remove the old TypedForm and PydanticModelForm stack

This commit is contained in:
wyzula_j authored and Jan Wyzula committed 2026-10-20 14:28:10 +02:00
1 parent 6ebaee8648
commit 480637f7cd
9 files changed
-1935

No files matched your search

-420
View File
@@ -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))
-701
View File
@@ -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()
@@ -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())
@@ -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()
@@ -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
)
@@ -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)]
@@ -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)
@@ -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}")
-43
View File
@@ -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"]