mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-29 16:21:27 +01:00
refactor(forms): remove the old TypedForm and PydanticModelForm stack
This commit is contained in:
1 parent
6ebaee8648
commit
480637f7cd
9 files changed
-1935
No files matched your search
@@ -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))
|
||||
@@ -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}")
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user