mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-30 00:21:27 +01:00
fix(scan_metadata): leave required numbers and yes/no fields empty until filled in
This commit is contained in:
1 parent
6692c7e547
commit
04a6488099
4 files changed
+200
-26
No files matched your search
@@ -10,6 +10,8 @@ from pydantic.fields import FieldInfo
|
||||
from qtpy.QtCore import QLocale, Qt
|
||||
from qtpy.QtCore import Signal as QtSignal
|
||||
from qtpy.QtWidgets import (
|
||||
QAbstractSpinBox,
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
@@ -17,6 +19,7 @@ from qtpy.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QRadioButton,
|
||||
QSpinBox,
|
||||
QWidget,
|
||||
)
|
||||
@@ -166,6 +169,67 @@ class OptionalValueWidget(QWidget):
|
||||
self.value_changed.emit(self.value())
|
||||
|
||||
|
||||
class TrueFalseEntry(QWidget):
|
||||
"""A "true"/"false" radio pair that can also have no choice made.
|
||||
|
||||
Used for required yes/no fields, so that a missing answer is not read as ``False``.
|
||||
|
||||
Attributes:
|
||||
changed: Signal emitted when the choice changes.
|
||||
"""
|
||||
|
||||
changed = QtSignal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
"""Create the radio pair with no choice made.
|
||||
|
||||
Args:
|
||||
parent: Optional parent widget.
|
||||
"""
|
||||
super().__init__(parent=parent)
|
||||
self._true = QRadioButton("true", self)
|
||||
self._false = QRadioButton("false", self)
|
||||
self._group = QButtonGroup(self)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
for button in (self._true, self._false):
|
||||
self._group.addButton(button)
|
||||
layout.addWidget(button)
|
||||
layout.addStretch(1)
|
||||
self._group.buttonToggled.connect(self._on_toggled)
|
||||
|
||||
def value(self) -> bool | None:
|
||||
"""Return the choice.
|
||||
|
||||
Returns:
|
||||
``True`` or ``False``, or ``None`` while no choice is made.
|
||||
"""
|
||||
if self._true.isChecked():
|
||||
return True
|
||||
if self._false.isChecked():
|
||||
return False
|
||||
return None
|
||||
|
||||
def set_value(self, value: bool | None) -> None:
|
||||
"""Select ``true`` or ``false``, or clear the choice for ``None``.
|
||||
|
||||
Args:
|
||||
value: The choice to show.
|
||||
"""
|
||||
if value is None:
|
||||
self._group.setExclusive(False)
|
||||
self._true.setChecked(False)
|
||||
self._false.setChecked(False)
|
||||
self._group.setExclusive(True)
|
||||
self.changed.emit()
|
||||
return
|
||||
(self._true if value else self._false).setChecked(True)
|
||||
|
||||
def _on_toggled(self, _button: QRadioButton, checked: bool) -> None:
|
||||
if checked:
|
||||
self.changed.emit()
|
||||
|
||||
|
||||
# pylint: disable-next=too-many-instance-attributes,too-many-public-methods
|
||||
class PydanticWidgetForm(QWidget):
|
||||
"""Generate a Qt form from a Pydantic model.
|
||||
@@ -195,7 +259,7 @@ class PydanticWidgetForm(QWidget):
|
||||
extra_fields_title: str = "Additional fields",
|
||||
extra_fields_hint: str = "",
|
||||
mark_required: bool = False,
|
||||
empty_text_as_missing: bool = False,
|
||||
required_start_empty: bool = False,
|
||||
show_field_errors: bool = False,
|
||||
) -> None:
|
||||
"""Create a generated form for a Pydantic model.
|
||||
@@ -213,9 +277,10 @@ class PydanticWidgetForm(QWidget):
|
||||
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
|
||||
required_start_empty: Leave required fields without a default empty (blank
|
||||
text and numbers, no choice for yes/no) and treat an empty one as a
|
||||
missing value, so validation reports it as required instead of
|
||||
accepting an empty string.
|
||||
accepting an empty string, the minimum or ``False``.
|
||||
show_field_errors: Show the validation message of an invalid field in a
|
||||
line directly under its input when :meth:`validate` runs.
|
||||
"""
|
||||
@@ -227,7 +292,7 @@ class PydanticWidgetForm(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._required_start_empty = required_start_empty
|
||||
self._show_field_errors = show_field_errors
|
||||
self._error_labels: dict[str, QLabel] = {}
|
||||
self._shown_errors: dict[str, str] = {}
|
||||
@@ -636,6 +701,8 @@ class PydanticWidgetForm(QWidget):
|
||||
if container in (dict, list, set, tuple):
|
||||
return EntryListEditor(self, key_value=container is dict, add_text="Add entry")
|
||||
if annotation is bool:
|
||||
if self._starts_empty(name):
|
||||
return TrueFalseEntry(self)
|
||||
return QCheckBox(self)
|
||||
if annotation is int:
|
||||
spin_box = QSpinBox(self)
|
||||
@@ -647,6 +714,15 @@ class PydanticWidgetForm(QWidget):
|
||||
return spin_box
|
||||
return QLineEdit(self)
|
||||
|
||||
def _starts_empty(self, name: str) -> bool:
|
||||
"""Whether the input of field ``name`` starts empty, see ``required_start_empty``."""
|
||||
info = self._model.model_fields[name]
|
||||
return (
|
||||
self._required_start_empty
|
||||
and info.is_required()
|
||||
and NoneType not in get_args(info.annotation)
|
||||
)
|
||||
|
||||
def _apply_field_metadata(self, name: str) -> None:
|
||||
config = self._field_configs[name]
|
||||
field_widget = self._widgets[name]
|
||||
@@ -675,9 +751,13 @@ class PydanticWidgetForm(QWidget):
|
||||
if isinstance(widget, OptionalValueWidget):
|
||||
widget.value_changed.connect(lambda _value: self.changed.emit())
|
||||
return
|
||||
if isinstance(widget, EntryListEditor):
|
||||
if isinstance(widget, (EntryListEditor, TrueFalseEntry)):
|
||||
widget.changed.connect(self.changed)
|
||||
return
|
||||
if isinstance(widget, QAbstractSpinBox):
|
||||
# The text too: emptying an input or typing the value it held while empty
|
||||
# changes only the text.
|
||||
widget.lineEdit().textChanged.connect(lambda *_args: self.changed.emit())
|
||||
WidgetIO.connect_widget_change_signal(widget, lambda *_args: self.changed.emit())
|
||||
|
||||
def _connect_device_signal_widgets(self) -> None:
|
||||
@@ -773,13 +853,9 @@ class PydanticWidgetForm(QWidget):
|
||||
|
||||
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()
|
||||
):
|
||||
if self._required_start_empty:
|
||||
for name in self._widgets:
|
||||
if self._starts_empty(name) and data.get(name) in ("", None):
|
||||
data.pop(name, None)
|
||||
return data
|
||||
|
||||
@@ -788,6 +864,9 @@ class PydanticWidgetForm(QWidget):
|
||||
error = QLabel(self)
|
||||
error.setWordWrap(True)
|
||||
error.setTextFormat(Qt.TextFormat.PlainText)
|
||||
# A wrapped label can be given more height than its text needs; keep the text
|
||||
# right under the input instead of centred in that space.
|
||||
error.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
|
||||
error.setVisible(False)
|
||||
self._layout.setWidget(self._layout.rowCount(), QFormLayout.ItemRole.FieldRole, error)
|
||||
self._error_labels[name] = error
|
||||
@@ -857,10 +936,14 @@ class PydanticWidgetForm(QWidget):
|
||||
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):
|
||||
if isinstance(widget, (OptionalValueWidget, EntryListEditor, TrueFalseEntry)):
|
||||
return widget.value()
|
||||
if (
|
||||
isinstance(widget, (QSpinBox, QDoubleSpinBox))
|
||||
and widget.cleanText() == ""
|
||||
and self._starts_empty(name)
|
||||
):
|
||||
return None
|
||||
if isinstance(widget, QDoubleSpinBox) and _value_annotation(info.annotation) is Decimal:
|
||||
exact = isinstance(widget, _ExactTextMixin) and widget.exact
|
||||
text = repr(widget.value()) if exact else f"{widget.value():.{widget.decimals()}f}"
|
||||
@@ -878,10 +961,7 @@ class PydanticWidgetForm(QWidget):
|
||||
|
||||
def _set_widget_value(self, name: str, value: Any) -> None:
|
||||
widget = self._widgets[name]
|
||||
if isinstance(widget, OptionalValueWidget):
|
||||
widget.set_value(value)
|
||||
return
|
||||
if isinstance(widget, EntryListEditor):
|
||||
if isinstance(widget, (OptionalValueWidget, EntryListEditor, TrueFalseEntry)):
|
||||
widget.set_value(value)
|
||||
return
|
||||
if isinstance(value, Decimal):
|
||||
@@ -891,7 +971,10 @@ class PydanticWidgetForm(QWidget):
|
||||
value = ""
|
||||
elif isinstance(widget, QCheckBox):
|
||||
value = False
|
||||
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
|
||||
elif isinstance(widget, QAbstractSpinBox):
|
||||
if self._starts_empty(name):
|
||||
widget.clear()
|
||||
return
|
||||
value = 0
|
||||
elif isinstance(widget, QComboBox):
|
||||
value = ""
|
||||
|
||||
@@ -83,7 +83,7 @@ class ScanMetadata(BECWidget, QWidget):
|
||||
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,
|
||||
required_start_empty=True,
|
||||
show_field_errors=True,
|
||||
)
|
||||
self._form.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
|
||||
|
||||
@@ -14,6 +14,7 @@ from bec_widgets.utils.forms_from_types.pydantic_widget_form import (
|
||||
EXTRA_FIELDS_KEY,
|
||||
OptionalValueWidget,
|
||||
PydanticWidgetForm,
|
||||
TrueFalseEntry,
|
||||
)
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
@@ -355,6 +356,95 @@ def test_pydantic_widget_form_decimal_list_dict_and_optional_literal(qtbot):
|
||||
assert form.get_data()["mode"] == "slow"
|
||||
|
||||
|
||||
class RequiredNumbersSchema(BaseModel):
|
||||
count: int = Field(gt=0)
|
||||
energy: float
|
||||
dose: Decimal = Field(gt=0, decimal_places=1)
|
||||
mounted: bool
|
||||
repeats: int = 3
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
def test_pydantic_widget_form_required_start_empty(qtbot):
|
||||
plain = PydanticWidgetForm(RequiredNumbersSchema)
|
||||
strict = PydanticWidgetForm(RequiredNumbersSchema, required_start_empty=True)
|
||||
qtbot.addWidget(plain)
|
||||
qtbot.addWidget(strict)
|
||||
|
||||
# default behaviour is unchanged: numbers start at their minimum, yes/no is a checkbox
|
||||
assert plain.input_widget("count").value() == 1
|
||||
assert isinstance(plain.input_widget("mounted"), QCheckBox)
|
||||
assert plain.validate()
|
||||
|
||||
# required fields start empty and are reported as missing, defaults stay filled in
|
||||
for name in ("count", "energy", "dose"):
|
||||
assert strict.input_widget(name).text() == "", name
|
||||
assert isinstance(strict.input_widget("mounted"), TrueFalseEntry)
|
||||
assert strict.input_widget("repeats").value() == 3
|
||||
assert isinstance(strict.input_widget("enabled"), QCheckBox)
|
||||
assert strict.validation_errors() == {
|
||||
name: "Required - please fill in" for name in ("count", "energy", "dose", "mounted")
|
||||
}
|
||||
|
||||
strict.set_partial_data({"count": 2, "energy": 7.5, "dose": Decimal("0.5"), "mounted": False})
|
||||
assert strict.validate()
|
||||
assert strict.get_data()["mounted"] is False
|
||||
assert strict.get_data()["count"] == 2
|
||||
|
||||
# clearing a value makes it missing again
|
||||
strict.set_partial_data({"count": None, "mounted": None})
|
||||
assert set(strict.validation_errors()) == {"count", "mounted"}
|
||||
|
||||
|
||||
def test_pydantic_widget_form_empty_number_edits_emit_changed(qtbot):
|
||||
form = PydanticWidgetForm(RequiredNumbersSchema, required_start_empty=True)
|
||||
qtbot.addWidget(form)
|
||||
count = form.input_widget("count")
|
||||
assert count.text() == ""
|
||||
|
||||
# typing the value the empty input already holds still changes the form
|
||||
with qtbot.waitSignal(form.changed):
|
||||
qtbot.keyClicks(count, str(count.value()))
|
||||
assert form.raw_data()["count"] == count.value()
|
||||
assert "count" not in form.validation_errors()
|
||||
|
||||
# the arrows change the form too
|
||||
with qtbot.waitSignal(form.changed):
|
||||
count.stepUp()
|
||||
assert form.raw_data()["count"] == count.value()
|
||||
|
||||
# emptying a valid input makes it missing again
|
||||
with qtbot.waitSignal(form.changed):
|
||||
count.clear()
|
||||
assert form.validation_errors()["count"] == "Required - please fill in"
|
||||
|
||||
|
||||
class RequiredNumbersWithUnitsSchema(BaseModel):
|
||||
count: int = Field(json_schema_extra={"units": "s"})
|
||||
energy: float = Field(json_schema_extra={"units": "eV"})
|
||||
|
||||
|
||||
def test_pydantic_widget_form_empty_numbers_with_units_are_missing(qtbot):
|
||||
form = PydanticWidgetForm(RequiredNumbersWithUnitsSchema, required_start_empty=True)
|
||||
qtbot.addWidget(form)
|
||||
assert form.input_widget("count").text().strip() == "s"
|
||||
assert not form.validate()
|
||||
assert set(form.validation_errors()) == {"count", "energy"}
|
||||
|
||||
|
||||
def test_true_false_entry(qtbot):
|
||||
entry = TrueFalseEntry()
|
||||
qtbot.addWidget(entry)
|
||||
assert entry.value() is None
|
||||
with qtbot.waitSignal(entry.changed):
|
||||
entry.set_value(True)
|
||||
assert entry.value() is True
|
||||
entry.set_value(False)
|
||||
assert entry.value() is False
|
||||
entry.set_value(None)
|
||||
assert entry.value() is None
|
||||
|
||||
|
||||
def test_pydantic_widget_form_text_constraints_and_placeholder(qtbot):
|
||||
form = PydanticWidgetForm(GeneratedExtraSchema)
|
||||
qtbot.addWidget(form)
|
||||
@@ -365,9 +455,7 @@ def test_pydantic_widget_form_text_constraints_and_placeholder(qtbot):
|
||||
|
||||
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
|
||||
)
|
||||
strict = PydanticWidgetForm(GeneratedExtraSchema, mark_required=True, required_start_empty=True)
|
||||
qtbot.addWidget(plain)
|
||||
qtbot.addWidget(strict)
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ from qtpy.QtWidgets import QCheckBox, QDoubleSpinBox, QFormLayout, QLineEdit, QS
|
||||
|
||||
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.utils.forms_from_types.pydantic_widget_form import (
|
||||
OptionalValueWidget,
|
||||
TrueFalseEntry,
|
||||
)
|
||||
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
|
||||
from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox
|
||||
|
||||
@@ -100,7 +103,7 @@ def test_fields_use_matching_widgets(metadata_widget: ScanMetadata):
|
||||
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["bool_required_nodefault"], TrueFalseEntry)
|
||||
assert isinstance(widgets["int_default"], QSpinBox)
|
||||
assert isinstance(widgets["int_nodefault_optional"], OptionalValueWidget)
|
||||
assert isinstance(widgets["float_nodefault"], BECSpinBox)
|
||||
|
||||
Reference in new issue
Block a user