From 04a6488099e1a95f3e99546dfad8ef1ceb9d0186 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 19 Oct 2026 12:44:08 +0000 Subject: [PATCH] fix(scan_metadata): leave required numbers and yes/no fields empty until filled in --- .../forms_from_types/pydantic_widget_form.py | 123 +++++++++++++++--- .../editors/scan_metadata/scan_metadata.py | 2 +- tests/unit_tests/test_generated_form_form.py | 94 ++++++++++++- tests/unit_tests/test_scan_metadata.py | 7 +- 4 files changed, 200 insertions(+), 26 deletions(-) diff --git a/bec_widgets/utils/forms_from_types/pydantic_widget_form.py b/bec_widgets/utils/forms_from_types/pydantic_widget_form.py index 404f57ff..3e2a03b5 100644 --- a/bec_widgets/utils/forms_from_types/pydantic_widget_form.py +++ b/bec_widgets/utils/forms_from_types/pydantic_widget_form.py @@ -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 = "" diff --git a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py index 55814a2b..3e4ed202 100644 --- a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +++ b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py @@ -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) diff --git a/tests/unit_tests/test_generated_form_form.py b/tests/unit_tests/test_generated_form_form.py index 42671699..02768e85 100644 --- a/tests/unit_tests/test_generated_form_form.py +++ b/tests/unit_tests/test_generated_form_form.py @@ -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) diff --git a/tests/unit_tests/test_scan_metadata.py b/tests/unit_tests/test_scan_metadata.py index 21410b2b..fa96a7ab 100644 --- a/tests/unit_tests/test_scan_metadata.py +++ b/tests/unit_tests/test_scan_metadata.py @@ -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)