refactor(scan_metadata): build the scan metadata form on PydanticWidgetForm

This commit is contained in:
wyzula_j committed 2026-10-16 14:48:37 +00:00
1 parent 824979f7c4
commit 0a9b003bbf
9 files changed
+1144 -349

No files matched your search

@@ -0,0 +1,297 @@
"""Row-based editor for lists of values or key/value pairs, used by generated forms."""
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import Qt, Signal # type: ignore
from qtpy.QtWidgets import (
QHBoxLayout,
QLabel,
QLineEdit,
QSizePolicy,
QToolButton,
QVBoxLayout,
QWidget,
)
class _EntryRow(QWidget):
"""One editable row: an optional key field, a value field and a remove button."""
def __init__(
self, parent: QWidget, *, key_value: bool, key_placeholder: str, value_placeholder: str
) -> None:
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6)
self.key_edit: QLineEdit | None = None
if key_value:
self.key_edit = QLineEdit(self)
self.key_edit.setPlaceholderText(key_placeholder)
layout.addWidget(self.key_edit, 2)
self.value_edit = QLineEdit(self)
self.value_edit.setPlaceholderText(value_placeholder)
layout.addWidget(self.value_edit, 3)
self.remove_button = QToolButton(self)
self.remove_button.setAutoRaise(True)
self.remove_button.setIcon(material_icon("close", size=(16, 16), convert_to_pixmap=False))
self.remove_button.setToolTip("Remove this entry")
self.remove_button.setAccessibleName("Remove entry")
layout.addWidget(self.remove_button, 0)
def key(self) -> str:
"""Return the stripped key, or an empty string in plain-value mode."""
return self.key_edit.text().strip() if self.key_edit is not None else ""
def value(self) -> str:
"""Return the value text as typed."""
return self.value_edit.text()
def is_blank(self) -> bool:
"""Return whether both the key and the value are empty."""
return not self.key() and not self.value().strip()
class EntryListEditor(QWidget):
"""Edit a list of strings or a mapping of string keys to string values, one row per entry.
Blank rows are ignored when reading the value, so users can add a row and leave it empty
without making the surrounding form invalid.
Attributes:
changed: Emitted whenever an entry is added, removed or edited.
"""
changed = Signal()
def __init__( # pylint: disable=too-many-arguments
self,
parent: QWidget | None = None,
*,
key_value: bool = True,
key_placeholder: str = "Key",
value_placeholder: str = "Value",
add_text: str = "Add entry",
empty_text: str = "",
) -> None:
"""Create the editor.
Args:
parent: Optional parent widget.
key_value: Edit key/value pairs when ``True``, plain values when ``False``.
key_placeholder: Placeholder text of the key fields.
value_placeholder: Placeholder text of the value fields.
add_text: Text of the button that appends a row.
empty_text: Muted hint shown while there are no rows; hidden when empty.
"""
super().__init__(parent)
self._key_value = key_value
self._key_placeholder = key_placeholder
self._value_placeholder = value_placeholder
self._rows: list[_EntryRow] = []
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6)
self._rows_layout = QVBoxLayout()
self._rows_layout.setContentsMargins(0, 0, 0, 0)
self._rows_layout.setSpacing(6)
layout.addLayout(self._rows_layout)
self._empty_label = QLabel(empty_text, self)
self._empty_label.setWordWrap(True)
self._empty_label.setEnabled(False) # muted text in every theme
self._empty_label.setVisible(bool(empty_text))
layout.addWidget(self._empty_label)
self.add_button = QToolButton(self)
self.add_button.setText(add_text)
self.add_button.setIcon(material_icon("add", size=(16, 16), convert_to_pixmap=False))
self.add_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
self.add_button.setAutoRaise(True)
self.add_button.clicked.connect(self._add_and_focus)
layout.addWidget(self.add_button, 0, Qt.AlignmentFlag.AlignLeft)
@property
def key_value(self) -> bool:
"""Return whether rows edit key/value pairs (``True``) or plain values (``False``)."""
return self._key_value
def rows(self) -> list[_EntryRow]:
"""Return the row widgets, in display order."""
return list(self._rows)
def row_count(self) -> int:
"""Return the number of rows, blank ones included."""
return len(self._rows)
def add_entry(self, key: str = "", value: str = "") -> _EntryRow:
"""Append a row.
Args:
key: Initial key; ignored in plain-value mode.
value: Initial value.
Returns:
The new row widget.
"""
row = _EntryRow(
self,
key_value=self._key_value,
key_placeholder=self._key_placeholder,
value_placeholder=self._value_placeholder,
)
if row.key_edit is not None:
row.key_edit.setText(str(key))
row.key_edit.textChanged.connect(self._emit_changed)
row.value_edit.setText("" if value is None else str(value))
row.value_edit.textChanged.connect(self._emit_changed)
row.remove_button.clicked.connect(lambda _=False, r=row: self._remove_row(r))
self._rows.append(row)
self._rows_layout.addWidget(row)
self._update_empty_state()
self.changed.emit()
return row
def remove_entry(self, index: int) -> None:
"""Remove the row at ``index``.
Args:
index: Row index in display order.
"""
self._remove_row(self._rows[index])
def clear(self) -> None:
"""Remove all rows."""
for row in list(self._rows):
self._remove_row(row, notify=False)
self.changed.emit()
def entries(self) -> list[tuple[str, str]]:
"""Return the non-blank rows as ``(key, value)`` tuples; keys are empty in value mode."""
return [(row.key(), row.value()) for row in self._rows if not row.is_blank()]
def value(self) -> dict[str, str] | list[str]:
"""Return the content: a dict in key/value mode, a list of values otherwise."""
if self._key_value:
return dict(self.entries())
return [value for _key, value in self.entries()]
def set_value(self, value) -> None:
"""Replace all rows with ``value``.
Args:
value: A mapping in key/value mode, an iterable of values otherwise. ``None``
clears the editor.
"""
for row in list(self._rows):
self._remove_row(row, notify=False)
if value:
items = value.items() if self._key_value else (("", v) for v in value)
for key, item_value in items:
self.add_entry(key, item_value)
self._update_empty_state()
self.changed.emit()
def set_row_error(self, row: _EntryRow, message: str | None) -> None:
"""Mark a row's key field (or value field in value mode) as invalid.
Args:
row: Row widget returned by :meth:`rows`.
message: Error text shown as tooltip, or ``None`` to clear the error.
"""
target = row.key_edit if row.key_edit is not None else row.value_edit
target.setProperty("state", "error" if message else "")
target.setToolTip(message or "")
target.style().unpolish(target)
target.style().polish(target)
def _add_and_focus(self, *_args) -> None:
row = self.add_entry()
(row.key_edit or row.value_edit).setFocus()
def _remove_row(self, row: _EntryRow, notify: bool = True) -> None:
self._rows.remove(row)
self._rows_layout.removeWidget(row)
row.setParent(None)
row.deleteLater()
self._update_empty_state()
if notify:
self.changed.emit()
def _update_empty_state(self) -> None:
self._empty_label.setVisible(bool(self._empty_label.text()) and not self._rows)
def _emit_changed(self, *_args) -> None:
self.changed.emit()
class ExtraFieldsSection(QWidget):
"""Titled key/value editor for fields that are not part of the model.
Used by ``PydanticWidgetForm`` for models that accept extra fields
(``model_config = ConfigDict(extra="allow")``).
"""
def __init__(self, parent: QWidget | None = None, *, title: str, empty_text: str) -> None:
"""Create the section.
Args:
parent: Optional parent widget.
title: Section heading.
empty_text: Hint shown while there are no extra fields.
"""
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 6, 0, 0)
layout.setSpacing(4)
self.title_label = QLabel(title, self)
font = self.title_label.font()
font.setBold(True)
self.title_label.setFont(font)
layout.addWidget(self.title_label)
self.editor = EntryListEditor(
self,
key_value=True,
key_placeholder="Key",
value_placeholder="Value",
add_text="Add field",
empty_text=empty_text,
)
layout.addWidget(self.editor)
def row_errors(self, reserved_keys: set[str]) -> dict[int, str]:
"""Return error messages of invalid rows, keyed by row index.
Args:
reserved_keys: Keys that are taken by the form's own fields.
Returns:
Messages for rows with a value but no key, a reserved key or a repeated key.
"""
errors: dict[int, str] = {}
seen: set[str] = set()
for index, row in enumerate(self.editor.rows()):
if row.is_blank():
continue
key = row.key()
if not key:
errors[index] = "Enter a key for the value."
elif key in reserved_keys:
errors[index] = f"'{key}' is already a field of this form."
elif key in seen:
errors[index] = f"Key '{key}' is used more than once."
seen.add(key)
return errors
def show_row_errors(self, reserved_keys: set[str]) -> None:
"""Highlight the invalid rows and clear the highlight of valid ones.
Args:
reserved_keys: Keys that are taken by the form's own fields.
"""
errors = self.row_errors(reserved_keys)
for index, row in enumerate(self.editor.rows()):
self.editor.set_row_error(row, errors.get(index))
@@ -10,6 +10,20 @@ from pydantic_core import PydanticUndefined
from bec_widgets.utils.scan_arg_metadata import ui_config_from_metadata
NUMERIC_BOUND_KEYS = {"gt", "ge", "lt", "le"}
# Annotated constraints that map onto form metadata keys, e.g. ``decimal_places`` -> ``precision``
_CONSTRAINT_KEYS = {key: key for key in NUMERIC_BOUND_KEYS} | {
"decimal_places": "precision",
"max_length": "max_length",
}
def _constraint_metadata(entry: object) -> dict[str, Any]:
"""Return the form metadata carried by one ``FieldInfo.metadata`` constraint entry."""
return {
target: getattr(entry, key)
for key, target in _CONSTRAINT_KEYS.items()
if getattr(entry, key, None) is not None
}
def pydantic_model_input_configs(model: type[BaseModel]) -> list[dict[str, Any]]:
@@ -21,10 +35,8 @@ def pydantic_model_input_configs(model: type[BaseModel]) -> list[dict[str, Any]]
if isinstance(entry, ScanArgument):
metadata.update(entry.model_dump(exclude_none=True))
continue
for key in NUMERIC_BOUND_KEYS:
value = getattr(entry, key, None)
if value is not None:
metadata.setdefault(key, value)
for key, value in _constraint_metadata(entry).items():
metadata.setdefault(key, value)
if isinstance(info.json_schema_extra, Mapping):
metadata.update(dict(info.json_schema_extra))
@@ -1,7 +1,8 @@
from __future__ import annotations
from types import NoneType
from typing import Any, Literal, get_args, get_origin
from decimal import Decimal
from types import NoneType, UnionType
from typing import Any, Literal, Union, get_args, get_origin
from bec_lib.device import DeviceBase, Signal
from pydantic import BaseModel, ValidationError
@@ -14,11 +15,14 @@ from qtpy.QtWidgets import (
QDoubleSpinBox,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QSpinBox,
QWidget,
)
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.forms_from_types.entry_list_editor import EntryListEditor, ExtraFieldsSection
from bec_widgets.utils.forms_from_types.pydantic_model_info_adapter import (
NUMERIC_BOUND_KEYS,
pydantic_model_input_configs,
@@ -34,6 +38,17 @@ from bec_widgets.widgets.control.device_input.device_combobox.device_combobox im
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox
#: Key of :meth:`PydanticWidgetForm.validation_errors` entries about the extra fields editor.
EXTRA_FIELDS_KEY = "__extra_fields__"
def _value_annotation(annotation: Any) -> Any:
"""Return the annotation without ``None`` for ``X | None``; unchanged otherwise."""
non_none_args = tuple(arg for arg in get_args(annotation) if arg is not NoneType)
if NoneType in get_args(annotation) and len(non_none_args) == 1:
return non_none_args[0]
return annotation
class OptionalValueWidget(QWidget):
"""Wrap a value widget with an enable checkbox for optional Pydantic fields.
@@ -116,6 +131,7 @@ class OptionalValueWidget(QWidget):
self.value_changed.emit(self.value())
# pylint: disable-next=too-many-instance-attributes,too-many-public-methods
class PydanticWidgetForm(QWidget):
"""Generate a Qt form from a Pydantic model.
@@ -132,7 +148,7 @@ class PydanticWidgetForm(QWidget):
changed = QtSignal()
validity_changed = QtSignal(bool)
def __init__(
def __init__( # pylint: disable=too-many-arguments
self,
model: type[BaseModel],
parent: QWidget | None = None,
@@ -140,6 +156,12 @@ class PydanticWidgetForm(QWidget):
data: BaseModel | dict[str, Any] | None = None,
read_only_fields: set[str] | None = None,
client=None,
allow_extra_fields: bool = False,
extra_fields_title: str = "Additional fields",
extra_fields_hint: str = "",
mark_required: bool = False,
empty_text_as_missing: bool = False,
show_field_errors: bool = False,
) -> None:
"""Create a generated form for a Pydantic model.
@@ -150,6 +172,17 @@ class PydanticWidgetForm(QWidget):
read_only_fields: Field names that should be displayed but not editable.
client: Optional BEC client passed to domain-specific widgets such as
device and signal combo boxes.
allow_extra_fields: Show a key/value editor below the model fields for
entries that are not part of the model. Only useful for models with
``extra="allow"``.
extra_fields_title: Heading of the extra fields editor.
extra_fields_hint: Hint shown in the extra fields editor while it is empty.
mark_required: Append ``*`` to the labels of fields without a default.
empty_text_as_missing: Treat an empty text field without a default as a
missing value, so validation reports it as required instead of
accepting an empty string.
show_field_errors: Show the validation message of an invalid field in a
line directly under its input when :meth:`validate` runs.
"""
super().__init__(parent=parent)
self._model = model
@@ -158,6 +191,17 @@ class PydanticWidgetForm(QWidget):
self._widgets: dict[str, QWidget] = {}
self._field_configs: dict[str, dict[str, Any]] = {}
self._baseline: dict[str, Any] = {}
self._mark_required = mark_required
self._empty_text_as_missing = empty_text_as_missing
self._show_field_errors = show_field_errors
self._error_labels: dict[str, QLabel] = {}
self._shown_errors: dict[str, str] = {}
self._extra_section: ExtraFieldsSection | None = None
if allow_extra_fields:
self._extra_section = ExtraFieldsSection(
self, title=extra_fields_title, empty_text=extra_fields_hint
)
self._extra_section.editor.changed.connect(self.changed)
self._layout = QFormLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
@@ -245,6 +289,31 @@ class PydanticWidgetForm(QWidget):
widget for widget in self.input_widgets().values() if isinstance(widget, widget_type)
]
@property
def extra_fields_section(self) -> ExtraFieldsSection | None:
"""Return the extra fields section, or ``None`` if extra fields are disabled."""
return self._extra_section
def field_label(self, name: str) -> str:
"""Return the user-facing label of field ``name``, without the required marker."""
return self._field_configs[name]["display_name"]
def extra_data(self) -> dict[str, str]:
"""Return the non-blank extra fields as a mapping; empty when they are disabled."""
if self._extra_section is None:
return {}
return self._extra_section.editor.value()
def set_extra_data(self, data: dict[str, Any] | None) -> None:
"""Replace the entries of the extra fields editor.
Args:
data: Mapping of extra keys to values; ``None`` clears the editor.
"""
if self._extra_section is None:
return
self._extra_section.editor.set_value(data)
def set_model(self, model: type[BaseModel], data: dict[str, Any] | None = None) -> None:
"""Replace the active model and rebuild the form.
@@ -269,6 +338,10 @@ class PydanticWidgetForm(QWidget):
data: Pydantic model instance or raw field-value mapping.
"""
values = data.model_dump() if isinstance(data, BaseModel) else dict(data)
if self._extra_section is not None:
self.set_extra_data(
{key: value for key, value in values.items() if key not in self._widgets}
)
self.set_partial_data(values)
def set_partial_data(self, data: dict[str, Any]) -> None:
@@ -291,9 +364,13 @@ class PydanticWidgetForm(QWidget):
"""Return current widget values without Pydantic validation.
Returns:
Mapping of model field names to raw widget values.
Mapping of model field names to raw widget values, followed by the
entries of the extra fields editor that do not shadow a model field.
"""
return {name: self._read_widget_value(name) for name in self._widgets}
data = {name: self._read_widget_value(name) for name in self._widgets}
for key, value in self.extra_data().items():
data.setdefault(key, value)
return data
def get_data(self) -> dict[str, Any]:
"""Return current data after Pydantic validation.
@@ -318,21 +395,58 @@ class PydanticWidgetForm(QWidget):
ValueError: If domain widget validation fails.
"""
self._validate_domain_widgets()
return self._model.model_validate(self.raw_data())
self._validate_extra_fields()
return self._model.model_validate(self._validation_payload())
def validate(self) -> bool:
"""Validate the current form values.
"""Validate the current form values and highlight the fields that are invalid.
Returns:
``True`` when current values validate successfully, otherwise ``False``.
"""
errors = self.validation_errors()
self._show_errors(errors)
self.validity_changed.emit(not errors)
return not errors
def validation_errors(self) -> dict[str, str]:
"""Return a readable validation message for each invalid field.
Missing values read ``Required - please fill in``. Every failing entry of a
field is listed on its own line, prefixed with its location inside the field,
e.g. ``[1]: Input should be a valid integer``.
Returns:
Mapping of field names to error messages. Problems in the extra fields
editor are reported under :data:`EXTRA_FIELDS_KEY`; errors Pydantic
cannot attribute to a field are reported under ``""``. Empty when the
form is valid.
"""
found = dict(self._domain_widget_errors())
try:
self.get_data()
except (ValidationError, ValueError):
self.validity_changed.emit(False)
return False
self.validity_changed.emit(True)
return True
self._model.model_validate(self._validation_payload())
except ValidationError as exc:
for name, message in self._describe_validation_error(exc).items():
found.setdefault(name, message)
# Field order first, then problems outside the fields, then the extra fields.
errors = {name: found.pop(name) for name in self._widgets if name in found}
errors.update(found)
extra_errors = self._extra_field_errors()
if extra_errors:
errors[EXTRA_FIELDS_KEY] = next(iter(extra_errors.values()))
return errors
def field_error_label(self, name: str) -> QLabel | None:
"""Return the error line under field ``name``.
Args:
name: Model field name.
Returns:
The label, or ``None`` when the form was created without
``show_field_errors``.
"""
return self._error_labels.get(name)
def dirty_fields(self) -> set[str]:
"""Return fields whose raw values differ from the clean baseline.
@@ -382,6 +496,9 @@ class PydanticWidgetForm(QWidget):
def cleanup(self) -> None:
"""Close and schedule deletion of all generated field widgets."""
if self._extra_section is not None and self._layout.indexOf(self._extra_section) >= 0:
# The extra fields section outlives model changes; take it out before clearing.
self._layout.takeRow(self._extra_section)
while self._layout.rowCount():
row = self._layout.takeRow(0)
for item in (row.labelItem, row.fieldItem):
@@ -395,6 +512,8 @@ class PydanticWidgetForm(QWidget):
widget.deleteLater()
self._widgets.clear()
self._field_configs.clear()
self._error_labels.clear()
self._shown_errors.clear()
def closeEvent(self, event) -> None: # noqa: N802
self.cleanup()
@@ -406,6 +525,8 @@ class PydanticWidgetForm(QWidget):
info = self._model.model_fields[name]
widget = self._create_widget(name, info)
label_text = config["display_name"]
if self._mark_required and info.is_required():
label_text = f"{label_text} *"
self._layout.addRow(label_text, widget)
label = self._layout.labelForField(widget)
if label is not None:
@@ -418,6 +539,11 @@ class PydanticWidgetForm(QWidget):
self._set_widget_value(name, config["default"])
self._apply_field_metadata(name)
self._connect_widget(widget)
if self._show_field_errors:
self._add_error_label(name)
if self._extra_section is not None:
self._layout.addRow(self._extra_section)
self._connect_device_signal_widgets()
self._connect_reference_unit_widgets()
@@ -425,14 +551,13 @@ class PydanticWidgetForm(QWidget):
def _create_widget(self, name: str, info: FieldInfo) -> QWidget:
annotation = info.annotation
args = get_args(annotation)
optional = NoneType in args
non_none_args = tuple(arg for arg in args if arg is not NoneType)
value_annotation = non_none_args[0] if len(non_none_args) == 1 else annotation
optional = NoneType in get_args(annotation)
value_annotation = _value_annotation(annotation)
widget = self._create_value_widget(name, value_annotation)
numeric = value_annotation in (int, float) or any(
arg in (int, float) for arg in get_args(value_annotation)
numeric = value_annotation in (int, float, Decimal) or (
get_origin(value_annotation) in (Union, UnionType)
and any(arg in (int, float, Decimal) for arg in get_args(value_annotation))
)
if optional and (numeric or value_annotation is bool):
return OptionalValueWidget(widget, parent=self)
@@ -459,8 +584,17 @@ class PydanticWidgetForm(QWidget):
return DeviceComboBox(parent=self, client=self._client, arg_name=name)
if get_origin(annotation) is Literal:
widget = QComboBox(self)
if NoneType in get_args(self._model.model_fields[name].annotation):
widget.addItem("") # the empty entry stands for None
widget.addItems([str(value) for value in get_args(annotation)])
return widget
if annotation is Decimal:
spin_box = QDoubleSpinBox(self)
spin_box.setRange(-1_000_000_000, 1_000_000_000)
return spin_box
container = get_origin(annotation) or annotation
if container in (dict, list, set, tuple):
return EntryListEditor(self, key_value=container is dict, add_text="Add entry")
if annotation is bool:
return QCheckBox(self)
if annotation is int:
@@ -487,10 +621,20 @@ class PydanticWidgetForm(QWidget):
if input_widget is not field_widget:
apply_unit_metadata(input_widget, config)
if isinstance(input_widget, QLineEdit):
if config.get("max_length") is not None:
input_widget.setMaxLength(int(config["max_length"]))
placeholder = config.get("placeholder") or config.get("description")
if placeholder:
input_widget.setPlaceholderText(str(placeholder))
def _connect_widget(self, widget: QWidget) -> None:
if isinstance(widget, OptionalValueWidget):
widget.value_changed.connect(lambda _value: self.changed.emit())
return
if isinstance(widget, EntryListEditor):
widget.changed.connect(self.changed)
return
WidgetIO.connect_widget_change_signal(widget, lambda *_args: self.changed.emit())
def _connect_device_signal_widgets(self) -> None:
@@ -551,28 +695,131 @@ class PydanticWidgetForm(QWidget):
self._apply_reference_units(source_name, None)
def _validate_domain_widgets(self) -> None:
for widget in self._widgets.values():
for message in self._domain_widget_errors().values():
raise ValueError(message)
def _domain_widget_errors(self) -> dict[str, str]:
errors: dict[str, str] = {}
for name, widget in self._widgets.items():
if isinstance(widget, DeviceComboBox):
device = widget.currentText().strip()
if not device:
raise ValueError("Device is required.")
if not widget.is_valid_input:
raise ValueError(f"Device '{device}' is not available.")
errors[name] = "Device is required."
elif not widget.is_valid_input:
errors[name] = f"Device '{device}' is not available."
if isinstance(widget, SignalComboBox):
signal = widget.get_signal_name().strip()
if signal and not widget.is_valid_input:
raise ValueError(f"Signal '{signal}' is not available.")
errors[name] = f"Signal '{signal}' is not available."
return errors
def _validate_extra_fields(self) -> None:
for message in self._extra_field_errors().values():
raise ValueError(message)
def _extra_field_errors(self) -> dict[int, str]:
if self._extra_section is None:
return {}
return self._extra_section.row_errors(set(self._widgets))
def _validation_payload(self) -> dict[str, Any]:
data = self.raw_data()
if self._empty_text_as_missing:
for name, widget in self._widgets.items():
if (
isinstance(widget, QLineEdit)
and data.get(name) in ("", None)
and self._model.model_fields[name].is_required()
):
data.pop(name, None)
return data
def _add_error_label(self, name: str) -> None:
"""Add the hidden error line in the field column of the row under field ``name``."""
error = QLabel(self)
error.setWordWrap(True)
error.setTextFormat(Qt.TextFormat.PlainText)
error.setVisible(False)
self._layout.setWidget(self._layout.rowCount(), QFormLayout.ItemRole.FieldRole, error)
self._error_labels[name] = error
def _describe_validation_error(self, error: ValidationError) -> dict[str, str]:
"""Group Pydantic's errors by top-level field, without error codes and links."""
problems: dict[str, list[str]] = {}
for err in error.errors():
loc = tuple(err.get("loc") or ())
name = str(loc[0]) if loc else ""
if err["type"] == "missing" or err.get("input") is None:
message = "Required - please fill in"
else:
message = err["msg"]
path = self._error_path(name, loc[1:])
line = f"{path}: {message}" if path else message
lines = problems.setdefault(name, [])
if line not in lines:
lines.append(line)
return {name: "\n".join(lines) for name, lines in problems.items()}
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._model.model_fields.get(name)
annotation = _value_annotation(field.annotation) if field is not None else None
if get_origin(annotation) in (Union, UnionType) 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(".")
def _show_errors(self, errors: dict[str, str]) -> None:
"""Highlight invalid inputs and their labels; clear fields that became valid."""
error_color = get_accent_colors().emergency.name()
for name in set(self._shown_errors) | set(errors):
if name not in self._widgets:
continue
message = errors.get(name)
if self._shown_errors.get(name) == message:
continue
widget = self.input_widget(name)
widget.setProperty("state", "error" if message else "")
widget.style().unpolish(widget)
widget.style().polish(widget)
label = self._layout.labelForField(self._widgets[name])
if label is not None:
# Themes do not style QLabel[state="error"], so colour the label directly.
label.setStyleSheet(f"color: {error_color};" if message else "")
error_label = self._error_labels.get(name)
if error_label is not None:
error_label.setStyleSheet(f"color: {error_color};")
error_label.setText(message or "")
error_label.setVisible(message is not None)
if self._extra_section is not None:
self._extra_section.show_row_errors(set(self._widgets))
self._shown_errors = {name: msg for name, msg in errors.items() if name in self._widgets}
def _read_widget_value(self, name: str) -> Any:
widget = self._widgets[name]
info = self._model.model_fields[name]
if isinstance(widget, OptionalValueWidget):
return widget.value()
if isinstance(widget, EntryListEditor):
return widget.value()
if isinstance(widget, QDoubleSpinBox) and _value_annotation(info.annotation) is Decimal:
return Decimal(f"{widget.value():.{widget.decimals()}f}")
if isinstance(widget, QLineEdit):
value = WidgetIO.get_value(widget)
return None if NoneType in get_args(info.annotation) and value == "" else value
if isinstance(widget, QComboBox) and get_origin(info.annotation) is Literal:
return WidgetIO.get_value(widget, as_string=True)
if (
isinstance(widget, QComboBox)
and get_origin(_value_annotation(info.annotation)) is Literal
):
value = WidgetIO.get_value(widget, as_string=True)
return None if NoneType in get_args(info.annotation) and value == "" else value
return WidgetIO.get_value(widget)
def _set_widget_value(self, name: str, value: Any) -> None:
@@ -580,6 +827,11 @@ class PydanticWidgetForm(QWidget):
if isinstance(widget, OptionalValueWidget):
widget.set_value(value)
return
if isinstance(widget, EntryListEditor):
widget.set_value(value)
return
if isinstance(value, Decimal):
value = float(value)
if value is None:
if isinstance(widget, QLineEdit):
value = ""
@@ -587,6 +839,10 @@ class PydanticWidgetForm(QWidget):
value = False
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
value = 0
elif isinstance(widget, QComboBox):
value = ""
elif isinstance(widget, QLineEdit) and not isinstance(value, str):
value = str(value)
WidgetIO.set_value(widget, value)
def _model_has_device_field(self) -> bool:
@@ -614,7 +870,7 @@ if __name__ == "__main__": # pragma: no cover
from bec_lib.scan_args import ScanArgument
from pydantic import Field
from qtpy.QtWidgets import QApplication, QLabel, QPushButton, QTabWidget, QTextEdit, QVBoxLayout
from qtpy.QtWidgets import QApplication, QPushButton, QTabWidget, QTextEdit, QVBoxLayout
from bec_widgets.utils.colors import apply_theme
@@ -1,23 +1,54 @@
from __future__ import annotations
from decimal import Decimal
from types import NoneType
from typing import Any
from bec_lib.logger import bec_logger
from bec_lib.metadata_schema import get_metadata_schema_for_scan
from pydantic import Field
from qtpy.QtWidgets import QApplication, QComboBox, QHBoxLayout, QVBoxLayout, QWidget
from qtpy.QtCore import Qt, Signal # type: ignore
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QLabel,
QLineEdit,
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.expandable_frame import ExpandableGroupFrame
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
from bec_widgets.utils.forms_from_types.pydantic_widget_form import (
EXTRA_FIELDS_KEY,
PydanticWidgetForm,
)
logger = bec_logger.logger
class ScanMetadata(PydanticModelForm):
class ScanMetadata(BECWidget, QWidget):
"""Form for the metadata of a scan, generated from the scan's pydantic metadata schema.
Uses the metadata schema registry supplied in the plugin repo to find the pydantic model
associated with the scan, falling back to ``BasicScanMetadata``. Free key/value pairs that
are not part of the schema can be added in the "Additional metadata" section.
Attributes:
form_data_updated: Emitted with the validated metadata whenever the form is valid.
form_data_cleared: Emitted with ``None`` whenever the form is invalid.
validity_proc: Emitted with the validity of the form after every validation.
"""
PLUGIN = True
RPC = False
ICON_NAME = "list_alt"
form_data_updated = Signal(dict)
form_data_cleared = Signal(NoneType)
validity_proc = Signal(bool)
def __init__(
self,
@@ -27,45 +58,78 @@ class ScanMetadata(PydanticModelForm):
initial_extras: list[list[str]] | None = None,
**kwargs,
):
"""Dynamically generates a form for inclusion of metadata for a scan. Uses the
metadata schema registry supplied in the plugin repo to find pydantic models
associated with the scan type. Sets limits for numerical values if specified.
"""Create the metadata form.
Args:
scan_name (str): The scan for which to generate a metadata form
Initial_extras (list[list[str]]): Initial data with which to populate the additional
metadata table - inner lists should be key-value pairs
initial_extras (list[list[str]]): Initial key-value pairs of the additional
metadata section.
"""
# self.populate() gets called in super().__init__
# so make sure self._additional_metadata exists
self._additional_md_box = ExpandableGroupFrame(
parent, "Additional metadata", expanded=False
)
self._additional_md_box_layout = QHBoxLayout()
self._additional_md_box.set_layout(self._additional_md_box_layout)
self._additional_metadata = DictBackedTable(parent, initial_extras or [])
super().__init__(parent=parent, client=client, **kwargs)
self._scan_name = scan_name or ""
self._md_schema = get_metadata_schema_for_scan(self._scan_name)
self._additional_metadata.data_changed.connect(self.validate_form)
self._hide_optional_metadata = False
super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs)
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(4)
self._layout.setContentsMargins(0, 0, 0, 0)
self._form_grid_container.layout().setContentsMargins(0, 0, 0, 0)
self._layout.addWidget(self._additional_md_box)
self._additional_md_box_layout.addWidget(self._additional_metadata)
self._form = PydanticWidgetForm(
self._md_schema,
parent=self,
client=client,
allow_extra_fields=True,
extra_fields_title="Additional metadata",
extra_fields_hint="Store extra information with the scan as key-value pairs.",
mark_required=True,
empty_text_as_missing=True,
show_field_errors=True,
)
self._form.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(self._form)
self._validity_message = QLabel("Not yet validated", self)
self._validity_message.setWordWrap(True)
self._validity_message.setTextFormat(Qt.TextFormat.PlainText)
main_layout.addWidget(self._validity_message)
self._form.set_extra_data({str(k): v for k, v in (initial_extras or [])})
self._update_scan_name_placeholder()
self._form.changed.connect(self.validate_form)
@property
def form(self) -> PydanticWidgetForm:
"""The generated form holding the schema fields and the additional metadata."""
return self._form
@SafeSlot(str)
def update_with_new_scan(self, scan_name: str):
"""Switch to the metadata schema of ``scan_name`` and validate the form.
Values of fields shared by both schemas and the additional metadata are kept.
Args:
scan_name (str): Name of the newly selected scan.
"""
self.set_schema_from_scan(scan_name)
self.validate_form()
def set_schema_from_scan(self, scan_name: str | None):
"""Rebuild the form for the metadata schema of ``scan_name``.
Args:
scan_name (str | None): Name of the scan; ``None`` selects the default schema.
"""
self._scan_name = scan_name or ""
schema = get_metadata_schema_for_scan(self._scan_name)
if schema is not self._md_schema:
self._md_schema = schema
self._form.set_model(schema)
self._update_scan_name_placeholder()
@SafeProperty(bool)
def hide_optional_metadata(self): # type: ignore
"""Property to hide the optional metadata table."""
"""Property to hide the additional metadata section."""
return self._hide_optional_metadata
@hide_optional_metadata.setter
@@ -73,37 +137,109 @@ class ScanMetadata(PydanticModelForm):
"""Setter for the hide_optional_metadata property.
Args:
hide(bool): Hide or show the optional metadata table.
hide(bool): Hide or show the additional metadata section.
"""
self._hide_optional_metadata = hide
self._additional_md_box.setVisible(not hide)
self._form.extra_fields_section.setVisible(not hide)
def get_form_data(self):
"""Get the entered metadata as a dict"""
form_data = self._additional_metadata.dump_dict() | self._dict_from_grid()
def set_field_values(self, values: dict[str, Any]):
"""Fill schema fields; keys that are not fields of the current schema are ignored.
# If scan_name is empty, set it to the current scan
if "scan_name" in form_data and not form_data["scan_name"]:
form_data["scan_name"] = self._scan_name
Args:
values (dict): Mapping of field names to values.
"""
self._form.set_partial_data(values)
return form_data
def set_extra_metadata(self, extras: dict[str, Any] | None):
"""Replace the additional metadata key-value pairs.
def populate(self):
self._additional_metadata.update_disallowed_keys(list(self._md_schema.model_fields.keys()))
super().populate()
Args:
extras (dict | None): The new key-value pairs.
"""
self._form.set_extra_data(extras)
# Set scan_name field to current scan if it exists and is empty
if "scan_name" not in self.widget_dict:
return
scan_name_widget = self.widget_dict["scan_name"]
if not hasattr(scan_name_widget, "getValue") or scan_name_widget.getValue():
return
if hasattr(scan_name_widget, "setValue"):
scan_name_widget.setValue(self._scan_name)
def get_form_data(self) -> dict[str, Any]:
"""Get the entered metadata as a dict, without validating it.
def set_schema_from_scan(self, scan_name: str | None):
self._scan_name = scan_name or ""
self.set_schema(get_metadata_schema_for_scan(self._scan_name))
An empty scan name is replaced by the name of the current scan.
"""
return self._finalize(self._form.raw_data())
@SafeSlot()
def validate_form(self, *_) -> bool:
"""Validate the metadata against the schema and publish the result.
Emits the validated metadata on ``form_data_updated`` or ``None`` on
``form_data_cleared``. Each invalid field shows its problem under its input,
and a one-line summary under the form names the invalid fields; its tooltip
lists every problem.
Returns:
bool: Whether the metadata is valid.
"""
valid = self._form.validate()
accents = get_accent_colors()
if valid:
self._set_validity(accents.success.name(), "No errors!", "")
self.form_data_updated.emit(self._finalize(self._form.get_data()))
else:
errors = self._form.validation_errors()
titles = [self._error_title(name) for name in errors]
if len(titles) == 1:
summary = f"Invalid field: {titles[0]}"
else:
summary = f"{len(titles)} invalid fields: {', '.join(titles)}"
details = "\n".join(self._messages(errors))
self._set_validity(accents.emergency.name(), summary, details)
self.form_data_cleared.emit(None)
self.validity_proc.emit(valid)
return valid
def validation_messages(self) -> list[str]:
"""Return one readable line per problem, in form order.
Returns:
list[str]: Lines such as ``"Sample Name: Required - please fill in"``.
"""
return self._messages(self._form.validation_errors())
def _messages(self, errors: dict[str, str]) -> list[str]:
return [
f"{self._error_title(name)}: {line}"
for name, message in errors.items()
for line in message.split("\n")
]
def _error_title(self, name: str) -> str:
if name == EXTRA_FIELDS_KEY:
return "Additional metadata"
if name in self._form.widgets:
return self._form.field_label(name)
return name or "Form"
def _set_validity(self, color: str, summary: str, details: str):
self._validity_message.setStyleSheet(f"color: {color};")
self._validity_message.setText(summary)
self._validity_message.setToolTip(details)
def _finalize(self, data: dict[str, Any]) -> dict[str, Any]:
data = {
key: float(value) if isinstance(value, Decimal) else value
for key, value in data.items()
}
if "scan_name" in data and not data["scan_name"]:
data["scan_name"] = self._scan_name
return data
def _update_scan_name_placeholder(self):
widget = self._form.widgets.get("scan_name")
if isinstance(widget, QLineEdit):
widget.setPlaceholderText(self._scan_name or "Name of the selected scan")
def cleanup(self):
"""Release the generated field widgets."""
self._form.cleanup()
super().cleanup()
if __name__ == "__main__": # pragma: no cover
+3 -3
View File
@@ -7,7 +7,6 @@ try:
except ImportError:
pytest.skip(reason="Requires plugin repo!", allow_module_level=True)
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.scan_control import ScanControl
@@ -67,9 +66,10 @@ def test_scan_metadata_for_custom_scan(
def do_test():
# Set the metadata
for field_name, widget in scan_control._metadata_form.widget_dict.items():
form = scan_control._metadata_form.form
for field_name in list(form.widgets):
if (value_to_set := md.pop(field_name, None)) is not None:
widget.setValue(value_to_set)
form.set_partial_data({field_name: value_to_set})
# all values should be used
assert md == {}
assert scan_control.button_run_scan.isEnabled()
@@ -16,7 +16,6 @@ from qtpy.QtTest import QAbstractItemModelTester
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
INITIAL_DATA = [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]]
@@ -171,22 +170,6 @@ def test_tables_do_not_share_backing_data(qtbot):
assert second._table_model.rowCount() == 0
def test_scan_metadata_form_drops_deleted_extra_row(qtbot):
widget = create_widget(qtbot, ScanMetadata, initial_extras=[["k1", "v1"], ["k2", "v2"]])
received: list[dict] = []
widget.form_data_updated.connect(received.append)
table = widget._additional_metadata
selection = table._table_view.selectionModel()
selection.select(table._table_model.index(0, 0), QItemSelectionModel.SelectionFlag.Select)
table.delete_selected_rows()
assert table.dump_dict() == {"k2": "v2"}
assert received, "deleting a row must re-validate the form"
assert "k1" not in received[-1]
assert received[-1]["k2"] == "v2"
def test_values_are_not_checked_against_keys(table):
model = table._table_model
table.update_disallowed_keys(["forbidden"])
+120 -2
View File
@@ -1,16 +1,19 @@
from decimal import Decimal
from typing import Literal
from unittest.mock import patch
import pytest
from bec_lib.device import Device, Signal
from bec_lib.scan_args import ScanArgument
from pydantic import BaseModel, Field
from qtpy.QtWidgets import QCheckBox, QLabel, QLineEdit
from pydantic import BaseModel, ConfigDict, Field
from qtpy.QtWidgets import QCheckBox, QComboBox, QDoubleSpinBox, QLabel, QLineEdit
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,
PydanticWidgetForm,
)
@@ -326,3 +329,118 @@ def test_pydantic_widget_form_preserves_optional_bool_none(qtbot):
assert form.raw_data()["enabled"] is True
assert form.model_instance().enabled is True
class GeneratedExtraSchema(BaseModel):
model_config = ConfigDict(extra="allow")
sample: str = Field(title="Sample", description="Sample identifier", max_length=8)
dose: Decimal = Field(Decimal("0.50"), decimal_places=2, ge=0)
tags: list[int] = Field(default_factory=list)
settings: dict[str, int] = Field(default_factory=dict)
mode: Literal["fast", "slow"] | None = None
def test_pydantic_widget_form_extra_fields_round_trip(qtbot):
form = PydanticWidgetForm(GeneratedExtraSchema, allow_extra_fields=True)
qtbot.addWidget(form)
form.set_data({"sample": "s1", "operator": "jan"})
assert form.extra_data() == {"operator": "jan"}
assert form.raw_data()["operator"] == "jan"
assert form.get_data()["operator"] == "jan"
def test_pydantic_widget_form_extra_fields_survive_model_change(qtbot):
class Other(BaseModel):
model_config = ConfigDict(extra="allow")
sample: str = ""
form = PydanticWidgetForm(GeneratedExtraSchema, allow_extra_fields=True)
qtbot.addWidget(form)
form.set_data({"sample": "s1", "operator": "jan"})
form.set_model(Other)
assert form.layout().indexOf(form.extra_fields_section) >= 0
assert form.raw_data() == {"sample": "s1", "operator": "jan"}
def test_pydantic_widget_form_reports_invalid_extra_keys(qtbot):
form = PydanticWidgetForm(GeneratedExtraSchema, allow_extra_fields=True)
qtbot.addWidget(form)
form.set_partial_data({"sample": "s1"})
form.set_extra_data({"sample": "clash"})
assert form.validation_errors() == {
EXTRA_FIELDS_KEY: "'sample' is already a field of this form."
}
assert not form.validate()
with pytest.raises(ValueError, match="already a field"):
form.get_data()
row = form.extra_fields_section.editor.rows()[0]
assert row.key_edit.property("state") == "error"
def test_pydantic_widget_form_without_extra_fields_has_no_section(qtbot):
form = PydanticWidgetForm(GeneratedExtraSchema)
qtbot.addWidget(form)
assert form.extra_fields_section is None
assert form.extra_data() == {}
def test_pydantic_widget_form_decimal_list_dict_and_optional_literal(qtbot):
form = PydanticWidgetForm(GeneratedExtraSchema)
qtbot.addWidget(form)
dose = form.input_widget("dose")
assert type(dose) is QDoubleSpinBox
assert dose.decimals() == 2
assert form.raw_data()["dose"] == Decimal("0.50")
assert isinstance(form.input_widget("tags"), EntryListEditor)
assert not form.input_widget("tags").key_value
assert form.input_widget("settings").key_value
mode = form.input_widget("mode")
assert isinstance(mode, QComboBox)
assert [mode.itemText(i) for i in range(mode.count())] == ["", "fast", "slow"]
form.set_partial_data(
{"sample": "s1", "tags": [1, 2], "settings": {"gain": 3}, "dose": Decimal("1.25")}
)
data = form.get_data()
assert data["tags"] == [1, 2]
assert data["settings"] == {"gain": 3}
assert data["dose"] == Decimal("1.25")
assert data["mode"] is None
mode.setCurrentText("slow")
assert form.get_data()["mode"] == "slow"
def test_pydantic_widget_form_text_constraints_and_placeholder(qtbot):
form = PydanticWidgetForm(GeneratedExtraSchema)
qtbot.addWidget(form)
sample = form.input_widget("sample")
assert sample.maxLength() == 8
assert sample.placeholderText() == "Sample identifier"
def test_pydantic_widget_form_required_markers_and_empty_text(qtbot):
plain = PydanticWidgetForm(GeneratedExtraSchema)
strict = PydanticWidgetForm(
GeneratedExtraSchema, mark_required=True, empty_text_as_missing=True
)
qtbot.addWidget(plain)
qtbot.addWidget(strict)
# default behaviour is unchanged: an empty required text is an empty string
assert plain.layout().labelForField(plain.field_widget("sample")).text() == "Sample"
assert plain.validate()
assert strict.layout().labelForField(strict.field_widget("sample")).text() == "Sample *"
assert strict.validation_errors() == {"sample": "Required - please fill in"}
assert not strict.validate()
assert strict.input_widget("sample").property("state") == "error"
strict.input_widget("sample").setText("s1")
assert strict.validate()
assert strict.input_widget("sample").property("state") == ""
+14 -22
View File
@@ -8,11 +8,10 @@ import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import AvailableResourceMessage, ScanHistoryMessage
from bec_lib.scan_history import ScanHistory
from qtpy.QtCore import QModelIndex, QPoint, Qt
from qtpy.QtWidgets import QCheckBox, QComboBox, QDialog, QStyle
from qtpy.QtCore import QPoint, Qt
from qtpy.QtWidgets import QCheckBox, QComboBox, QDialog, QLineEdit, QStyle
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.utils.forms_from_types.items import StrFormItem
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.scan_control import ScanControl
@@ -1750,16 +1749,12 @@ def test_scan_metadata_is_updated_even_without_default_form_changes(
assert scan_control._metadata_form._scan_name == "line_scan"
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
assert scan_control._metadata_form._scan_name == "grid_scan"
scan_control._metadata_form._additional_metadata._add_button.click()
qtbot.wait(100)
table_model = scan_control._metadata_form._additional_metadata._table_model
model_key = table_model.index(0, 0, QModelIndex())
table_model.setData(model_key, "test key 1", Qt.EditRole)
model_value = model_key.siblingAtColumn(1)
table_model.setData(model_value, "test value 1", Qt.EditRole)
assert scan_control._metadata_form._additional_metadata.dump_dict() == {
"test key 1": "test value 1"
}
editor = scan_control._metadata_form.form.extra_fields_section.editor
editor.add_button.click()
row = editor.rows()[0]
row.key_edit.setText("test key 1")
row.value_edit.setText("test value 1")
assert editor.value() == {"test key 1": "test value 1"}
assert scan_control._scan_metadata == {
"comment": "",
"sample_name": "",
@@ -1772,22 +1767,19 @@ def test_scan_metadata_is_connected(scan_control):
assert scan_control._metadata_form._scan_name == "line_scan"
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
assert scan_control._metadata_form._scan_name == "grid_scan"
sample_name = scan_control._metadata_form.widget_dict["sample_name"]
assert isinstance(sample_name, StrFormItem)
sample_name._main_widget.setText("Test Sample")
sample_name = scan_control._metadata_form.form.input_widget("sample_name")
assert isinstance(sample_name, QLineEdit)
sample_name.setText("Test Sample")
scan_control._metadata_form._additional_metadata._table_model._data = TEST_TABLE_ENTRY
scan_control._metadata_form.validate_form()
scan_control._metadata_form.set_extra_metadata(dict(TEST_TABLE_ENTRY))
assert scan_control._scan_metadata == TEST_MD
def test_scan_metadata_is_passed_to_scan_function(scan_control: ScanControl):
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
sample_name = scan_control._metadata_form.widget_dict["sample_name"]
sample_name._main_widget.setText("Test Sample")
scan_control._metadata_form._additional_metadata._table_model._data = TEST_TABLE_ENTRY
scan_control._metadata_form.validate_form()
scan_control._metadata_form.form.input_widget("sample_name").setText("Test Sample")
scan_control._metadata_form.set_extra_metadata(dict(TEST_TABLE_ENTRY))
assert scan_control._scan_metadata == TEST_MD
+225 -224
View File
@@ -1,22 +1,18 @@
from decimal import Decimal
from typing import Set
import pytest
from bec_lib.metadata_schema import BasicScanMetadata
from pydantic import BaseModel, Field, ValidationError
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.utils.forms_from_types.items import (
BoolFormItem,
DictFormItem,
DynamicFormItem,
FloatDecimalFormItem,
IntFormItem,
StrFormItem,
)
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
# pylint: disable=no-member
# pylint: disable=missing-function-docstring
@@ -40,8 +36,27 @@ class ExampleSchema(BasicScanMetadata):
unsupported_class: Json = Field(default=set())
class TwoRequiredSchema(BasicScanMetadata):
sample_id: str = Field(title="Sample ID")
operator: str = Field(title="Operator")
TEST_VALUES = {
"sample_name": "test name",
"str_optional": None,
"str_required": "something",
"bool_optional": None,
"bool_required_nodefault": False,
"int_default": 21,
"int_nodefault_optional": -10,
"float_nodefault": 0.1,
"decimal_dp_limits_nodefault": 456.789,
"dict_default": {"test_dict": "values"},
"unsupported_class": '["set", "item"]',
}
TEST_DICT = {
"scan_name": "",
"scan_name": "example_scan",
"comment": "",
"sample_name": "test name",
"str_optional": None,
@@ -59,255 +74,241 @@ TEST_DICT = {
@pytest.fixture
def example_md():
return ExampleSchema.model_validate(TEST_DICT)
def schema_registry(monkeypatch):
monkeypatch.setattr(
"bec_lib.metadata_schema._get_metadata_schema_registry",
lambda: {"example_scan": ExampleSchema, "two_required": TwoRequiredSchema},
)
@pytest.fixture
def empty_metadata_widget(qtbot):
widget = ScanMetadata()
widget._additional_metadata._table_model._data = [["extra_field", "extra_data"]]
qtbot.addWidget(widget)
def metadata_widget(qtbot, schema_registry):
widget = create_widget(
qtbot,
ScanMetadata,
scan_name="example_scan",
initial_extras=[["extra_field", "extra_data"]],
)
yield widget
@pytest.fixture
def metadata_widget(empty_metadata_widget: ScanMetadata):
widget = empty_metadata_widget
widget._md_schema = ExampleSchema
widget.populate()
yield widget, widget.widget_dict
def test_fields_use_matching_widgets(metadata_widget: ScanMetadata):
widgets = metadata_widget.form.widgets
for name in ["scan_name", "comment", "sample_name", "str_required", "unsupported_class"]:
assert type(widgets[name]) is QLineEdit, name
assert isinstance(metadata_widget.form.input_widget("str_optional"), QLineEdit)
assert metadata_widget.form.input_widget("str_optional").maxLength() == 23
assert isinstance(widgets["bool_optional"], OptionalValueWidget)
assert isinstance(widgets["bool_required_default"], QCheckBox)
assert isinstance(widgets["bool_required_nodefault"], QCheckBox)
assert isinstance(widgets["int_default"], QSpinBox)
assert isinstance(widgets["int_nodefault_optional"], OptionalValueWidget)
assert isinstance(widgets["float_nodefault"], BECSpinBox)
decimal = widgets["decimal_dp_limits_nodefault"]
assert type(decimal) is QDoubleSpinBox
assert decimal.decimals() == 2
assert (decimal.minimum(), decimal.maximum()) == (pytest.approx(1.01), pytest.approx(34.5))
assert isinstance(widgets["dict_default"], EntryListEditor)
def fill_components(components: dict[str, DynamicFormItem]):
components["sample_name"].setValue("test name")
components["str_optional"].setValue(None)
components["str_required"].setValue("something")
components["bool_optional"].setValue(None)
components["bool_required_nodefault"].setValue(False)
components["int_default"].setValue(21)
components["int_nodefault_optional"].setValue(-10)
components["float_nodefault"].setValue(0.1)
components["decimal_dp_limits_nodefault"].setValue(456.789)
components["dict_default"].setValue({"test_dict": "values"})
components["unsupported_class"].setValue(r'["set", "item"]')
def test_griditems_are_correct_class(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
):
_, components = metadata_widget
assert isinstance(components["sample_name"], StrFormItem)
assert isinstance(components["str_optional"], StrFormItem)
assert isinstance(components["str_required"], StrFormItem)
assert isinstance(components["bool_optional"], BoolFormItem)
assert isinstance(components["bool_required_default"], BoolFormItem)
assert isinstance(components["bool_required_nodefault"], BoolFormItem)
assert isinstance(components["int_default"], IntFormItem)
assert isinstance(components["int_nodefault_optional"], IntFormItem)
assert isinstance(components["float_nodefault"], FloatDecimalFormItem)
assert isinstance(components["decimal_dp_limits_nodefault"], FloatDecimalFormItem)
assert isinstance(components["dict_default"], DictFormItem)
assert isinstance(components["unsupported_class"], StrFormItem)
def test_grid_to_dict(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]):
widget, components = metadata_widget = metadata_widget
fill_components(components)
assert widget._dict_from_grid() == TEST_DICT
assert widget.get_form_data() == TEST_DICT | {"extra_field": "extra_data"}
def test_validation(metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]]):
widget, components = metadata_widget = metadata_widget
assert widget._validity_state is None
assert widget._validity_message.text() == "Not yet validated"
fill_components(components)
widget.validate_form()
assert widget._validity_state is True
assert widget._validity_message.text() == "No errors!"
components["bool_required_nodefault"]._main_widget.clear()
widget.validate_form()
assert widget._validity_state is False
assert widget._validity_message.text() == "Invalid field: bool_required_nodefault"
assert (
widget._validity_message.toolTip() == "bool_required_nodefault: Required - please fill in"
def test_required_fields_are_marked(metadata_widget: ScanMetadata):
form = metadata_widget.form
assert form.layout().labelForField(form.field_widget("str_required")).text() == (
"Str required *"
)
components["bool_required_nodefault"].setValue(True)
components["float_nodefault"]._main_widget.clear()
widget.validate_form()
assert widget._validity_message.text() == "Invalid field: float_nodefault"
assert widget._validity_message.toolTip() == "float_nodefault: Required - please fill in"
components["float_nodefault"].setValue(True)
assert form.layout().labelForField(form.field_widget("comment")).text() == "Comment"
def test_validation_summary_stays_short(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
):
widget, components = metadata_widget
fill_components(components)
components["bool_required_nodefault"]._main_widget.clear()
components["float_nodefault"]._main_widget.clear()
widget.validate_form()
summary = widget._validity_summary
assert summary == "2 invalid fields: bool_required_nodefault, float_nodefault"
def test_form_data_includes_extras_and_scan_name(metadata_widget: ScanMetadata, qtbot):
metadata_widget.set_field_values(TEST_VALUES)
received = []
metadata_widget.form_data_updated.connect(received.append)
assert metadata_widget.validate_form()
# the emitted metadata is validated, so the Json field arrives parsed
assert received[-1] == TEST_DICT | {
"unsupported_class": ["set", "item"],
"extra_field": "extra_data",
}
# Decimals are sent as floats, like the other numbers
assert isinstance(received[-1]["decimal_dp_limits_nodefault"], float)
assert metadata_widget.get_form_data()["extra_field"] == "extra_data"
def test_validation_messages(metadata_widget: ScanMetadata):
assert metadata_widget._validity_message.text() == "Not yet validated"
metadata_widget.set_field_values(TEST_VALUES | {"str_required": ""})
cleared = []
metadata_widget.form_data_cleared.connect(cleared.append)
assert not metadata_widget.validate_form()
assert cleared == [None]
assert metadata_widget.validation_messages() == ["Str required: Required - please fill in"]
assert metadata_widget._validity_message.text() == "Invalid field: Str required"
assert metadata_widget._validity_message.toolTip() == "Str required: Required - please fill in"
assert metadata_widget.form.input_widget("str_required").property("state") == "error"
metadata_widget.set_field_values({"str_required": "something"})
assert metadata_widget.validate_form()
assert metadata_widget._validity_message.text() == "No errors!"
assert metadata_widget._validity_message.toolTip() == ""
assert metadata_widget.form.input_widget("str_required").property("state") == ""
def test_validation_summary_stays_short(metadata_widget: ScanMetadata):
metadata_widget.set_field_values(TEST_VALUES | {"str_required": ""})
metadata_widget.set_extra_metadata({"comment": "clash"})
assert not metadata_widget.validate_form()
summary = metadata_widget._validity_message.text()
assert summary == "2 invalid fields: Str required, Additional metadata"
assert "\n" not in summary
assert "errors.pydantic.dev" not in widget._validity_message.toolTip()
assert "errors.pydantic.dev" not in metadata_widget._validity_message.toolTip()
def test_validation_summary_uses_field_titles(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
):
widget, _ = metadata_widget
with pytest.raises(ValidationError) as excinfo:
ExampleSchema.model_validate(TEST_DICT | {"str_optional": "x" * 30})
assert "Optional string" in widget._summarize_errors(excinfo.value)
def test_validation_errors_under_each_field(metadata_widget: ScanMetadata):
metadata_widget.update_with_new_scan("two_required")
form = metadata_widget.form
errors = {name: form.field_error_label(name) for name in form.widgets}
shown = {name for name, label in errors.items() if label.isVisibleTo(metadata_widget)}
assert shown == {"sample_id", "operator"}
assert errors["operator"].text() == "Required - please fill in"
assert "errors.pydantic.dev" not in errors["operator"].text()
assert metadata_widget._validity_message.text() == "2 invalid fields: Sample ID, Operator"
form.input_widget("sample_id").setText("s1")
shown = {name for name, label in errors.items() if label.isVisibleTo(metadata_widget)}
assert shown == {"operator"}
assert metadata_widget._validity_message.text() == "Invalid field: Operator"
form.input_widget("operator").setText("jan")
assert not any(label.isVisibleTo(metadata_widget) for label in errors.values())
assert metadata_widget._validity_message.text() == "No errors!"
def test_validation_summary_groups_union_and_list_errors(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
):
class UnionListSchema(BasicScanMetadata):
u: int | float = Field(title="Union")
lst: list[int] = Field(default_factory=list, title="List")
widget, _ = metadata_widget
with pytest.raises(ValidationError) as excinfo:
UnionListSchema.model_validate({"u": "x", "lst": [1, "a", "b"]})
widget._md_schema = UnionListSchema
assert widget._summarize_errors(excinfo.value) == "2 invalid fields: Union, List"
def test_validation_error_line_sits_under_its_field(metadata_widget: ScanMetadata):
form = metadata_widget.form
layout = form.layout()
for name, widget in form.widgets.items():
field_row, _ = layout.getWidgetPosition(widget)
error_row, role = layout.getWidgetPosition(form.field_error_label(name))
assert (error_row, role) == (field_row + 1, QFormLayout.ItemRole.FieldRole)
def test_validation_issues_are_readable(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
):
class ReadableSchema(BasicScanMetadata):
name: str = Field(max_length=3, title="Name")
count: int = Field(title="Count")
value: Decimal = Field(decimal_places=1, title="Value")
widget, _ = metadata_widget
widget._md_schema = ReadableSchema
with pytest.raises(ValidationError) as excinfo:
ReadableSchema.model_validate({"name": "toolong", "count": None, "value": Decimal("1.25")})
issues = widget._describe_errors(excinfo.value)
assert [(i.field, i.title, i.entered) for i in issues] == [
("name", "Name", "'toolong'"),
("count", "Count", "empty"),
("value", "Value", "1.25"),
]
assert issues[0].problem == "String should have at most 3 characters"
assert issues[1].problem == "Required - please fill in"
def test_validation_error_lines_follow_schema_change(metadata_widget: ScanMetadata):
metadata_widget.update_with_new_scan("other_scan")
form = metadata_widget.form
assert {name for name in form.widgets if form.field_error_label(name)} == {
"scan_name",
"comment",
"sample_name",
}
metadata_widget.update_with_new_scan("two_required")
assert form.field_error_label("operator").isVisibleTo(metadata_widget)
assert form.field_error_label("str_required") is None
def test_validation_issues_keep_nested_locations(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
):
class Detector(BaseModel):
prefix: str
gain: int
def test_validation_messages_list_every_failing_entry(metadata_widget: ScanMetadata):
class NestedSchema(BasicScanMetadata):
positions: list[int] = Field(default_factory=list, title="Positions")
offsets: dict[str, int] = Field(default_factory=dict, title="Offsets")
detector: Detector | None = Field(None, title="Detector")
value: int | float = Field(0, title="Value")
widget, _ = metadata_widget
widget._md_schema = NestedSchema
metadata_widget.form.set_model(NestedSchema)
with pytest.raises(ValidationError) as excinfo:
NestedSchema.model_validate(
{
"positions": [1, "a", 3, "b"],
"offsets": {"x": 1, "y": "bad"},
"detector": {"gain": "hi"},
"value": "x",
}
)
issues = {issue.field: issue for issue in widget._describe_errors(excinfo.value)}
positions = issues["positions"].problem.split("\n")
assert [line.split(":")[0] for line in positions] == ["[1]", "[3]"]
assert issues["offsets"].problem.startswith("y: Input should be a valid integer")
assert issues["detector"].problem.split("\n") == [
"prefix: Required - please fill in",
"gain: Input should be a valid integer, unable to parse string as an integer",
]
NestedSchema.model_validate({"positions": [1, "a", 3, "b"], "value": "x"})
problems = metadata_widget.form._describe_validation_error(excinfo.value)
assert [line.split(":")[0] for line in problems["positions"].split("\n")] == ["[1]", "[3]"]
# union members are tagged with their type name, which is not a location
assert not any(
line.startswith(("int:", "float:")) for line in issues["value"].problem.split("\n")
)
assert len(issues["value"].problem.split("\n")) == 2
assert not any(line.startswith(("int:", "float:")) for line in problems["value"].split("\n"))
assert len(problems["value"].split("\n")) == 2
def test_validation_errors_under_each_field(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
):
widget, components = metadata_widget
errors = widget._field_errors
assert set(errors) == set(components)
assert not any(label.isVisibleTo(widget) for label in errors.values())
fill_components(components)
components["bool_required_nodefault"]._main_widget.clear()
components["float_nodefault"]._main_widget.clear()
widget.validate_form()
shown = {name for name, label in errors.items() if label.isVisibleTo(widget)}
assert shown == {"bool_required_nodefault", "float_nodefault"}
assert errors["float_nodefault"].text() == "Required - please fill in"
assert "errors.pydantic.dev" not in errors["float_nodefault"].text()
components["bool_required_nodefault"].setValue(True)
widget.validate_form()
shown = {name for name, label in errors.items() if label.isVisibleTo(widget)}
assert shown == {"float_nodefault"}
components["float_nodefault"].setValue(1.5)
widget.validate_form()
assert not any(label.isVisibleTo(widget) for label in errors.values())
def test_numbers_clipped_to_limits(metadata_widget: ScanMetadata):
metadata_widget.set_field_values(TEST_VALUES | {"decimal_dp_limits_nodefault": -56})
assert metadata_widget.get_form_data()["decimal_dp_limits_nodefault"] == pytest.approx(1.01)
assert metadata_widget.validate_form()
def test_validation_error_line_sits_under_its_field(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
):
widget, components = metadata_widget
grid = widget._form_grid.layout()
rows = list(widget.enumerate_form_widgets())
assert len(rows) == len(components) # error lines are not form rows
for row in rows:
name = row.label.property("_model_field_name")
index = grid.indexOf(widget._field_errors[name])
assert grid.getItemPosition(index)[:2] == (row.i + 1, 1)
def test_editing_a_field_revalidates(metadata_widget: ScanMetadata):
metadata_widget.set_field_values(TEST_VALUES)
received = []
metadata_widget.validity_proc.connect(received.append)
metadata_widget.form.input_widget("str_required").setText("x")
assert received[-1] is True
metadata_widget.form.input_widget("str_required").setText("")
assert received[-1] is False
def test_validation_error_lines_follow_schema_change(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
):
class OtherSchema(BasicScanMetadata):
count: int = Field(title="Count")
@pytest.mark.parametrize(
"key, message",
[
("sample_name", "'sample_name' is already a field of this form."),
("extra_field", "Key 'extra_field' is used more than once."),
("", "Enter a key for the value."),
],
)
def test_invalid_additional_metadata_keys(metadata_widget: ScanMetadata, key, message):
metadata_widget.set_field_values(TEST_VALUES)
editor = metadata_widget.form.extra_fields_section.editor
row = editor.add_entry(key, "value")
widget, _ = metadata_widget
widget.set_schema(OtherSchema)
assert set(widget._field_errors) == {"scan_name", "comment", "sample_name", "count"}
widget.validate_form()
assert widget._field_errors["count"].isVisibleTo(widget)
assert widget._validity_message.text() == "Invalid field: Count"
assert not metadata_widget.validate_form()
assert metadata_widget.validation_messages() == [f"Additional metadata: {message}"]
assert row.key_edit.property("state") == "error"
row.key_edit.setText("unique_key")
assert metadata_widget.validate_form()
assert row.key_edit.property("state") == ""
assert metadata_widget.get_form_data()["unique_key"] == "value"
def test_numbers_clipped_to_limits(
metadata_widget: tuple[ScanMetadata, dict[str, DynamicFormItem]],
):
widget, components = metadata_widget = metadata_widget
fill_components(components)
def test_blank_additional_metadata_rows_are_ignored(metadata_widget: ScanMetadata):
metadata_widget.set_field_values(TEST_VALUES)
metadata_widget.form.extra_fields_section.editor.add_button.click()
components["decimal_dp_limits_nodefault"].setValue(-56)
assert components["decimal_dp_limits_nodefault"].getValue() == pytest.approx(1.01)
widget.validate_form()
assert widget._validity_message.text() == "No errors!"
assert metadata_widget.validate_form()
assert "" not in metadata_widget.get_form_data()
def test_switching_scans_keeps_shared_values_and_extras(metadata_widget: ScanMetadata):
metadata_widget.set_field_values(TEST_VALUES | {"comment": "keep me"})
metadata_widget.update_with_new_scan("other_scan")
assert metadata_widget._md_schema is BasicScanMetadata
assert set(metadata_widget.form.widgets) == {"scan_name", "comment", "sample_name"}
assert metadata_widget.form.input_widget("scan_name").placeholderText() == "other_scan"
assert metadata_widget.get_form_data() == {
"scan_name": "other_scan",
"comment": "keep me",
"sample_name": "test name",
"extra_field": "extra_data",
}
def test_typed_scan_name_is_kept(metadata_widget: ScanMetadata):
metadata_widget.set_field_values({"scan_name": "my alignment"})
assert metadata_widget.get_form_data()["scan_name"] == "my alignment"
def test_hide_optional_metadata(metadata_widget: ScanMetadata):
section = metadata_widget.form.extra_fields_section
assert section.isVisibleTo(metadata_widget)
metadata_widget.hide_optional_metadata = True
assert metadata_widget.hide_optional_metadata
assert not section.isVisibleTo(metadata_widget)
def test_scan_metadata_form_drops_deleted_extra_row(qtbot):
widget = create_widget(qtbot, ScanMetadata, initial_extras=[["k1", "v1"], ["k2", "v2"]])
received: list[dict] = []
widget.form_data_updated.connect(received.append)
editor = widget.form.extra_fields_section.editor
editor.rows()[0].remove_button.click()
assert editor.value() == {"k2": "v2"}
assert received, "deleting a row must re-validate the form"
assert "k1" not in received[-1]
assert received[-1]["k2"] == "v2"
@pytest.fixture