mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-27 08:01:27 +01:00
refactor(scan_metadata): build the scan metadata form on PydanticWidgetForm
This commit is contained in:
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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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") == ""
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user