Source code for autopilot.gui.widgets.model

"""
Widget to fill fields for a pydantic model
"""
from typing import Union, List, Optional, Tuple, Type, Dict, Literal
from pprint import pformat

from pydantic import BaseModel
from pydantic.fields import ModelField
from pydantic.error_wrappers import ValidationError
from pydantic.main import ModelMetaclass

from PySide2 import QtWidgets

from autopilot.gui.widgets.input import Input
from autopilot.root import Autopilot_Type
from autopilot.gui.dialog import pop_dialog
from autopilot.utils.loggers import init_logger
from autopilot.data.interfaces.base import resolve_type


[docs]class ModelWidget(QtWidgets.QWidget): """ Recursive collection of all inputs for a given model. Each attribute that has a single :class:`.Input` (eg. a single number, string, and so on) that can be resolved by :func:`~.interfaces.base.resolve_type` is represented by a :class:`.Model_Input`. Otherwise, attributes that are themselves other models are recursively added additional :class:`.ModelWidget` s. When a model's field is :class:`typing.Optional`, passed as :attr:`.ModelWidget.optional` , The groupbox for the model has a checkbox. When it is unchecked, the model fields are inactive and it is returned by :meth:`.ModelWidget.value` as ``None``. (Shouldn't be used with a top-level model.) """ def __init__(self, model:Union[BaseModel, Type[BaseModel]], optional:bool=False, scroll:bool=True, **kwargs): """ Args: model (:class:`pydantic.BaseModel`): The model to represent. Can either be a model class or an instantiated model. If an instantiated model, the fields are filled with the current values. optional (bool): If ``True``, the enclosing groupbox has a checkbox that when unchecked causes :meth:`ModelWidget.value` to return ``None``. If ``False``, :meth:`ModelWidget.value` always attempts to return the model scroll (bool): Whether the widget should be within a scrollbar. ``True`` by default, but should probably be ``False`` for child models. **kwargs: passed to superclass """ super(ModelWidget, self).__init__(**kwargs) if isinstance(model, BaseModel): # given instantiated model to prefill with self._model = model self.model = self._model.__class__ elif isinstance(model, type) and issubclass(model, BaseModel): self._model = None self.model = model else: raise ValueError(f"Need either an instantiated model or a model class, got {model}") self.optional = optional # make an outer layout to contain inner containers for individual model instances self._outer_layout = QtWidgets.QVBoxLayout() self._outer_layout.setContentsMargins(0,0,0,0) self.setLayout(self._outer_layout) # make a container for the instance of the model self.container, self.layout = self._make_container(self.model) # make scrollable if scroll: scroll = QtWidgets.QScrollArea() scroll.setWidgetResizable(True) scroll.setWidget(self.container) self._outer_layout.addWidget(scroll) else: self._outer_layout.addWidget(self.container) # make input widgets self.inputs: Dict[str, Union[Input, 'ModelWidget']] = self._make_fields() if self._model is not None: self.setValue(self._model)
[docs] def setValue(self, model: Union[BaseModel, dict]): """ Set all values of the form given an instantiated model. To set values of individual inputs, use :meth:`Input.setValue` """ if isinstance(model, dict): dict_ = model else: dict_ = model.dict() for key, value in dict_.items(): self.inputs[key].setValue(value)
[docs] def value(self) -> ['BaseModel', None]: """ Return an instance of the model populated with values from :meth:`.dict` If model fails to validate, pop a dialog with the validation errors and return None (see :meth:`.validate`) Returns: :class:`pydantic.BaseModel` of the type specified in :attr:`ModelWidget.model` """ validated = self.validate() if isinstance(validated, self.model): return validated else: return None
[docs] def dict(self) -> Union[dict,None]: """ Return a (recursive) dictionary of all current model values. Returns: dict None: if model is optional and unchecked. """ kwargs = {} if self.optional and self.container.isChecked(): return None for key, input in self.inputs.items(): if isinstance(input, (ModelWidget, ListModelWidget)): kwargs[key] = input.dict() else: kwargs[key] = input.value() return kwargs
def _make_container(self, model) -> (QtWidgets.QGroupBox, QtWidgets.QVBoxLayout): container = QtWidgets.QGroupBox(model.__name__) layout = QtWidgets.QVBoxLayout() container.setLayout(layout) if self.optional: container.setCheckable(True) container.setChecked(False) return container, layout def _make_fields(self) -> Dict[str, Union[Input, 'ModelWidget']]: """ Make fields, using :class:`.Input` subclasses to create the widgets and query values """ inputs = {} for key, field in self.model.__fields__.items(): is_list = False if hasattr(field, 'outer_type_') and getattr(field.outer_type_, '__origin__', False) is list: is_list= True if issubclass(field.type_.__class__, ModelMetaclass): # handle nested models recursively if is_list: widget = ListModelWidget(model=field.type_, optional=not field.required, scroll=False) else: widget = ModelWidget(model=field.type_, optional=not field.required,scroll=False) inputs[key] = widget self.layout.addWidget(widget) else: input, widget = self._make_field(field) inputs[key] = input label = self._make_label(field, self.model) h_layout = QtWidgets.QHBoxLayout() h_layout.addWidget(label) h_layout.addWidget(widget) self.layout.addLayout(h_layout) return inputs def _make_field(self, field:ModelField) -> Tuple[Input, QtWidgets.QWidget]: type_ = resolve_type(field.type_) if hasattr(field, 'outer_type_') and getattr(field.outer_type_, '__origin__', None) is list: input_class = Input.from_type(list) input = input_class() elif hasattr(field, 'outer_type_') and getattr(field.outer_type_, '__origin__', None) is Literal: input_class = Input.from_type(Literal) choices = field.outer_type_.__args__ default = field.default input = input_class(choices=choices, default=default) else: input_class = Input.from_type(type_) input = input_class() widget = input.make() return input, widget def _make_label(self, field:ModelField, model:Type[BaseModel]) -> QtWidgets.QLabel: """ Given a model field key, make a label widget with a tooltip! """ optional = not field.required # Make the label using the schema titel and description title = model.schema()['properties'][field.name]['title'] if optional: title += " (Optional)" description = model.schema()['properties'][field.name]['description'] label = QtWidgets.QLabel(title) label.setToolTip(description) return label
[docs] def validate(self, kwargs:Optional[dict] = None, dialog:bool=False) -> Union[List[dict], Autopilot_Type, BaseModel]: """ Test whether the given inputs pass model validation, and if not return which fail Args: dialog (bool): Whether or not to pop a dialogue showing which fields failed to validate """ if kwargs is None: kwargs = self.dict() try: instance = self.model(**kwargs) return instance except ValidationError as e: # get errors and return! errors = e.errors() if dialog: pop_dialog("Validation Error!", details=f"Validation errors with the following fields:\n{pformat(errors)}", msg_type='error', modality='modal').exec_() return errors
[docs]class ListModelWidget(QtWidgets.QWidget): """ Container class to make lists of :class:`.ModelWidget` s for when a field is a ``List`` """ def __init__(self, model:Union[BaseModel, Type[BaseModel]], optional:bool=False, scroll:bool=True, **kwargs): """ Args: model (:class:`pydantic.BaseModel`): The model to represent. Can either be a model class or an instantiated model. If an instantiated model, the fields are filled with the current values. optional (bool): If ``True``, the enclosing groupbox has a checkbox that when unchecked causes :meth:`ModelWidget.value` to return ``None``. If ``False``, :meth:`ModelWidget.value` always attempts to return the model scroll (bool): Whether the widget should be within a scrollbar. ``True`` by default, but should probably be ``False`` for child models **kwargs: Attributes: model_layout (:class:`~PySide2.QtWidgets.QVBoxLayout`): Layout containing model widgets add_button (:class:`~PySide2.QtWidgets.QPushButton`): Button pressed to add new models remove_button (:class:`~PySide2.QtWidgets.QPushButton`): Button pressed to remove the bottom-most model """ super(ListModelWidget, self).__init__(**kwargs) if isinstance(model, BaseModel): # given instantiated model to prefill with self._model = model self.model = self._model.__class__ elif isinstance(model, type) and issubclass(model, BaseModel): self._model = None self.model = model self.optional = optional self.layout = QtWidgets.QVBoxLayout() self.setLayout(self.layout) self.model_layout = QtWidgets.QVBoxLayout() self.container = QtWidgets.QGroupBox(self.model.__name__) self.container.setLayout(self.model_layout) if scroll: scroll = QtWidgets.QScrollArea() scroll.setWidgetResizable(True) scroll.setWidget(self.container) self.layout.addWidget(scroll) else: self.layout.addWidget(self.container) if self.optional: self.container.setCheckable(True) self.container.setChecked(False) self.add_button = QtWidgets.QPushButton("+") self.remove_button = QtWidgets.QPushButton('-') self.add_button.clicked.connect(self.add_model) self.remove_button.clicked.connect(self.remove_model) hlayout = QtWidgets.QHBoxLayout() hlayout.addWidget(self.remove_button) hlayout.addWidget(self.add_button) self.layout.addLayout(hlayout) self.model_widgets = [] # type: List[ModelWidget] if not self.optional: self.model_widgets.append(ModelWidget(self.model,optional=False))
[docs] def dict(self) -> List[dict]: """Sort of a misnomer, but return a list of dictionaries that contain the values to be used in the model""" return [m.dict() for m in self.model_widgets]
[docs] def value(self) -> List[BaseModel]: """A list of instantiated models""" return [m.value() for m in self.model_widgets]
[docs] def add_model(self, checked:bool=False, model:Optional[BaseModel]=None): """ When the :attr:`.add_button` is pressed, add an additional :class:`.ModelWidget` Args: checked (bool): Whether the button is checked (from the ``clicked`` signal) model (:class:`pydantic.BaseModel`): Manually override the model to construct. (default is to use the ``.model`` attribute) """ if model is None: model = self.model widget = ModelWidget(model=model, scroll=False) self.model_widgets.append(widget) self.model_layout.addWidget(widget)
[docs] def remove_model(self, checked:bool=False): """ When the :attr:`.remove_button` is pressed, remove the last-added :class:`.ModelWidget` Args: checked (bool): Whether the button is checked (from the ``clicked`` signal) """ w = self.model_layout.takeAt(len(self.model_widgets)-1) w.widget().deleteLater() self.model_widgets = self.model_widgets[:-1]
[docs] def setValue(self, value: List[BaseModel]): """ Create and set values for a list of instantiated data models. First clears any existing models that have been made. Args: value (list[BaseModel]): List of instantiated base models. """ self._clear_widgets() for model in value: if isinstance(model, dict): model = self.model(**model) self.add_model(model=model)
def _clear_widgets(self): while True: widget = self.model_layout.takeAt(0) if widget is None: break widget.widget().deleteLater() self.model_widgets = []
[docs]class Model_Filler_Dialogue(QtWidgets.QDialog): """ Dialogue wrapper around :class:`.ModelWidget` """ def __init__(self, model: Union[Type[Autopilot_Type], Type[BaseModel]], **kwargs): super(Model_Filler_Dialogue, self).__init__(**kwargs) self.model = model self.filler = ModelWidget(model) self.logger = init_logger(self) self.value = None buttonBox = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel) buttonBox.accepted.connect(self._accept) buttonBox.rejected.connect(self.reject) mainLayout = QtWidgets.QVBoxLayout() mainLayout.addWidget(self.filler) mainLayout.addWidget(buttonBox) self.setLayout(mainLayout) def _accept(self): """ Pre-wrapper before :meth:`.accept`, check that the model validates. If not, raise error dialogue """ self.logger.debug('Clicked OK button') model = self.filler.validate() if isinstance(model, self.model): self.value = model self.accept() self.logger.debug("Accepted model input") else: pop_dialog("Validation Error!", details=f"Validation errors with the following fields:\n{model}", msg_type='error')