Source code for dawiq.delegate

"""
Dataclass delegate
==================

"""

import dataclasses
from .qt_compat import QtWidgets, TypeRole, DataRole
from .datawidget import DataWidget
from .multitype import DataclassStackedWidget, DataclassTabWidget
from .typing import DataclassProtocol
from typing import Type, Dict, Any, Optional


__all__ = [
    "convertFromQt",
    "convertToQt",
    "highlightEmptyField",
    "DataclassDelegate",
    "DataclassMapper",
]


[docs]def convertFromQt( dcls: Type[DataclassProtocol], data: Dict[str, Any], ignoreMissing=True, ) -> Dict[str, Any]: """ Convert the dict from :class:`DataWidget` to structured dict for dataclass. If the field value does not exist in *data* or is :obj:`None`, it is considered to be absent. If *ignoreMissing* is True, the absent value is not included in the resulting dictionary. Else, the default value of the field is used if there is any. Field may define ``fromQt_converter`` metadata to convert the widget data to field data. It is a unary callable which takes the widget data and returns the field data. Examples ======== ``None`` is considered as missing value. >>> from dataclasses import dataclass, field >>> from dawiq.delegate import convertFromQt >>> from typing import Optional >>> @dataclass ... class Cls1: ... a: int ... b: int = 10 ... c: Optional[int] = None ... d: list = field(default_factory=list) >>> convertFromQt(Cls1, {}) {} >>> convertFromQt(Cls1, dict(a=1, b=None, c=None)) {'a': 1} *ignoreMissing* controls whether the default value should be used. >>> convertFromQt(Cls1, {}, ignoreMissing=False) {'b': 10, 'c': None, 'd': []} Nested dataclasses are recusively converted. >>> @dataclass ... class Cls2: ... x: int = 20 ... y: Cls1 = field(default_factory=lambda: Cls1(a=5)) >>> convertFromQt(Cls2, {}, ignoreMissing=False) {'x': 20, 'y': {'a': 5, 'b': 10, 'c': None, 'd': []}} ``fromQt_converter`` metadata converts the data from the widet. >>> def conv(arg): ... return (arg,) >>> @dataclass ... class Cls3: ... x: tuple = field(default=(1,), metadata=dict(fromQt_converter=conv)) >>> convertFromQt(Cls3, dict(x=1)) {'x': (1,)} """ # Return value is not dataclass but dictionary because necessary fields might # be missing from the widget. ret: Dict[str, Any] = {} for f in dataclasses.fields(dcls): val = data.get(f.name, None) t = f.type if val is None: if ignoreMissing: continue val = ( f.default_factory() if f.default_factory is not dataclasses.MISSING else f.default ) if val is dataclasses.MISSING: if dataclasses.is_dataclass(t): val = convertFromQt(t, {}, ignoreMissing) else: continue if dataclasses.is_dataclass(val) and not isinstance(val, type): val = dataclasses.asdict(val) else: if dataclasses.is_dataclass(t): val = convertFromQt(t, val, ignoreMissing) converter = f.metadata.get("fromQt_converter", None) if converter is not None: val = converter(val) ret[f.name] = val return ret
[docs]def convertToQt( dcls: Type[DataclassProtocol], data: Dict[str, Any], ignoreMissing=True, ) -> Dict[str, Any]: """ Convert structured dict from dataclass to dict for :class:`DataWidget`. If the field value does not exist in *data* or is :obj:`None`, it is considered to be absent. If *ignoreMissing* is True, ``None`` is used as the placeholder in the resulting dictionary. Else, the default value of the field is used if there is any. Field may define ``toQt_converter`` metadata to convert the field data to widget data. It is a unary callable which takes the field data and returns the widget data. Examples ======== ``None`` is considered as missing value. >>> from dataclasses import dataclass, field >>> from dawiq.delegate import convertToQt >>> from typing import Optional >>> @dataclass ... class Cls1: ... a: int ... b: int = 10 ... c: Optional[int] = None ... d: list = field(default_factory=list) >>> convertToQt(Cls1, dict(a=1, b=2, c=3)) {'a': 1, 'b': 2, 'c': 3, 'd': None} >>> convertToQt(Cls1, {}) {'a': None, 'b': None, 'c': None, 'd': None} *ignoreMissing* controls whether the default value should be used. >>> convertToQt(Cls1, {}, ignoreMissing=False) {'a': None, 'b': 10, 'c': None, 'd': []} Nested dataclasses are recusively converted. >>> @dataclass ... class Cls2: ... x: int = 20 ... y: Cls1 = field(default_factory=lambda: Cls1(a=5)) >>> convertToQt(Cls2, {}, ignoreMissing=False) {'x': 20, 'y': {'a': 5, 'b': 10, 'c': None, 'd': []}} ``toQt_converter`` metadata converts the data to the widget. >>> @dataclass ... class Cls3: ... x: int = field(metadata=dict(toQt_converter=lambda tup: tup[0])) >>> convertToQt(Cls3, dict(x=(1,))) {'x': 1} """ ret: Dict[str, Any] = {} for f in dataclasses.fields(dcls): val = data.get(f.name, None) t = f.type if val is None: if ignoreMissing: ret[f.name] = None continue val = ( f.default_factory() if f.default_factory is not dataclasses.MISSING else f.default ) if val is dataclasses.MISSING: if dataclasses.is_dataclass(t): val = convertToQt(t, {}, ignoreMissing) else: ret[f.name] = None continue if dataclasses.is_dataclass(val) and not isinstance(val, type): val = dataclasses.asdict(val) else: if dataclasses.is_dataclass(t): val = convertToQt(t, val, ignoreMissing) converter = f.metadata.get("toQt_converter", None) if converter is not None: val = converter(val) ret[f.name] = val return ret
[docs]def highlightEmptyField(editor: DataWidget, dcls: Optional[Type[DataclassProtocol]]): """Recursively highlight the empty field whose data is required.""" if dcls is None: editor.setRequired(False) else: # get field widgets from *editor* field_widgets = {} for i in range(editor.count()): widget = editor.widget(i) if widget is None: continue field_widgets[widget.fieldName()] = widget # if the field does not have default value, the field is required. for f in dataclasses.fields(dcls): widget = field_widgets.pop(f.name, None) if widget is None: # no widget for field continue default = ( f.default_factory() if f.default_factory is not dataclasses.MISSING else f.default ) required = default is dataclasses.MISSING if isinstance(widget, DataWidget): if required and dataclasses.is_dataclass(f.type): highlightEmptyField(widget, f.type) else: widget.setRequired(required) else: widget.setRequired(required)
[docs]class DataclassDelegate(QtWidgets.QStyledItemDelegate): """ Delegate to update the model and the data widget. This delegate stores dataclass type and dataclass data to the model, and updates the data widget with model data. Supported data widgets are: * :class:`DataWidget` * :class:`DataclassStackedWidget` * :class:`DataclassTabWidget` Dataclass type is stored to the model with :attr:`TypeRole` as item data role and dataclass data is stored with :attr:`DataRole`. By default, missing values are not replaced by default values of the fields. This is to preserve the intentional empty input by the user. Setting :meth:`ignoreMissing` changes this behavior. """ TypeRole = TypeRole DataRole = DataRole def __init__(self, parent=None): super().__init__(parent) self._ignoreMissing = True
[docs] def ignoreMissing(self) -> bool: """If True, default values are used for missing fields.""" return self._ignoreMissing
def setIgnoreMissing(self, val: bool): self._ignoreMissing = val def setModelData(self, editor, model, index): if isinstance(editor, (DataclassStackedWidget, DataclassTabWidget)): dcls = editor.currentDataclass() self.cacheModelData(model, index, dcls, self.TypeRole) self.setModelData(editor.currentWidget(), model, index) elif isinstance(editor, DataWidget): dcls = model.data(index, role=self.TypeRole) data = editor.dataValue() if dcls is not None: data = convertFromQt(dcls, data, self.ignoreMissing()) self.cacheModelData(model, index, data, self.DataRole) super().setModelData(editor, model, index)
[docs] @classmethod def cacheModelData(cls, model, index, value, role): """ Cache the *value* to *model*'s *index* with *role* so that ``submit()`` method of *model* can permanently store the data. Caching API is not defined for general ``QAbstractItemModel``. So by default, this method just stores the data to the model without caching. Subclass may reimplement this method to actually cache the data to the model. """ model.setData(index, value, role)
def setEditorData(self, editor, index): if isinstance(editor, (DataclassStackedWidget, DataclassTabWidget)): dcls = index.data(role=self.TypeRole) if dcls is not None: widgetIndex = editor.indexOfDataclass(dcls) else: widgetIndex = -1 editor.setCurrentIndex(widgetIndex) self.setEditorData(editor.currentWidget(), index) elif isinstance(editor, DataWidget): dcls = index.data(role=self.TypeRole) data = index.data(role=self.DataRole) if data is None: data = {} if dcls is not None: data = convertToQt(dcls, data, self.ignoreMissing()) editor.setDataValue(data) highlightEmptyField(editor, dcls) super().setEditorData(editor, index)
[docs]class DataclassMapper(QtWidgets.QDataWidgetMapper): """ Mapper between the data widget and the model. Supported data widgets are: * :class:`DataWidget` * :class:`DataclassStackedWidget` * :class:`DataclassTabWidget` Notes ===== When mapping :class:`DataWidget`, *propertyName* argument of :meth:`addMapping` must not be passed. """ def __init__(self, parent=None): super().__init__(parent) self.setSubmitPolicy(self.SubmitPolicy.ManualSubmit) def addMapping(self, widget, section, propertyName=b""): if isinstance(widget, DataclassStackedWidget): widget.currentDataEdited.connect(self.submit) elif isinstance(widget, DataclassTabWidget): widget.activated.connect(self.submit) widget.currentDataEdited.connect(self.submit) elif isinstance(widget, DataWidget): widget.dataEdited.connect(self.submit) super().addMapping(widget, section, propertyName) def removeMapping(self, widget): if isinstance(widget, DataclassStackedWidget): widget.currentDataEdited.disconnect(self.submit) elif isinstance(widget, DataclassTabWidget): widget.activated.disconnect(self.submit) widget.currentDataEdited.disconnect(self.submit) elif isinstance(widget, DataWidget): widget.dataEdited.disconnect(self.submit) super().removeMapping(widget)