From 86f669c4ba1ad84b194918224e537dde824a8f4b Mon Sep 17 00:00:00 2001 From: tangkong Date: Fri, 27 Sep 2024 14:43:26 -0700 Subject: [PATCH 01/22] ENH/WIP: refactor sub pv and nestable tables into self-contained custom QTableViews for simplicity of use and ease of extensibility --- superscore/tests/test_page.py | 8 +- superscore/ui/collection_builder_page.ui | 16 +- superscore/widgets/page/collection_builder.py | 111 ++------- superscore/widgets/views.py | 219 ++++++++++++++++-- 4 files changed, 245 insertions(+), 109 deletions(-) diff --git a/superscore/tests/test_page.py b/superscore/tests/test_page.py index 8f0482d..f812050 100644 --- a/superscore/tests/test_page.py +++ b/superscore/tests/test_page.py @@ -30,8 +30,8 @@ def collection_builder_page(qtbot: QtBot, sample_client: Client): page = CollectionBuilderPage(client=sample_client) qtbot.addWidget(page) yield page - page.pv_model.stop_polling() - qtbot.waitUntil(lambda: page.pv_model._poll_thread.isFinished()) + page.close() + qtbot.waitUntil(lambda: page.sub_pv_table_view._model._poll_thread.isFinished()) @pytest.mark.parametrize( @@ -79,10 +79,10 @@ def test_coll_builder_add(collection_builder_page: CollectionBuilderPage): assert len(page.data.children) == 1 assert "THIS:PV" in page.data.children[0].pv_name assert isinstance(page.data.children[0], Parameter) - assert page.pv_model.rowCount() == 1 + assert page.sub_pv_table_view._model.rowCount() == 1 page.coll_combo_box.setCurrentIndex(0) added_collection = page._coll_options[0] page.add_collection_button.clicked.emit() assert added_collection is page.data.children[1] - assert page.coll_model.rowCount() == 1 + assert page.sub_coll_table_view._model.rowCount() == 1 diff --git a/superscore/ui/collection_builder_page.ui b/superscore/ui/collection_builder_page.ui index e62e688..58d5b31 100644 --- a/superscore/ui/collection_builder_page.ui +++ b/superscore/ui/collection_builder_page.ui @@ -60,8 +60,8 @@ Qt::Vertical - - + + 0 @@ -202,6 +202,18 @@ + + + LivePVTableView + QTableView +
superscore.widgets.views
+
+ + NestableTableView + QTableView +
superscore.widgets.views
+
+
diff --git a/superscore/widgets/page/collection_builder.py b/superscore/widgets/page/collection_builder.py index 912d7d0..3bb7cca 100644 --- a/superscore/widgets/page/collection_builder.py +++ b/superscore/widgets/page/collection_builder.py @@ -9,9 +9,9 @@ from superscore.widgets.core import DataWidget, Display, NameDescTagsWidget from superscore.widgets.enhanced import FilterComboBox from superscore.widgets.manip_helpers import insert_widget -from superscore.widgets.views import (BaseTableEntryModel, ButtonDelegate, - LivePVHeader, LivePVTableModel, - NestableTableModel, RootTree) +from superscore.widgets.views import (BaseTableEntryModel, LivePVHeader, + LivePVTableView, NestableTableView, + RootTree) logger = logging.getLogger(__name__) @@ -25,14 +25,14 @@ class CollectionBuilderPage(Display, DataWidget): tree_view: QtWidgets.QTreeView - sub_coll_table_view: QtWidgets.QTableView - sub_pv_table_view: QtWidgets.QTableView + sub_coll_table_view: NestableTableView + sub_pv_table_view: LivePVTableView tab_widget: QtWidgets.QTabWidget # PV tab pv_line_edit: QtWidgets.QLineEdit rbv_line_edit: QtWidgets.QLineEdit - # Colleciton tab + # Collection tab add_collection_button: QtWidgets.QPushButton coll_combo_box: FilterComboBox coll_combo_box_placeholder: QtWidgets.QComboBox @@ -55,8 +55,6 @@ def __init__( super().__init__(*args, data=data, **kwargs) self.client = client self.open_page_slot = open_page_slot - self.pv_model = None - self.coll_model = None self.tree_model = None self._coll_options: list[Collection] = [] self._title = self.data.title @@ -78,30 +76,20 @@ def setup_ui(self): self.add_pvs_button.clicked.connect(self.add_pv) self.ro_checkbox.stateChanged.connect(self.set_rbv_enabled) - self.update_model_data() + # set up views + self.sub_pv_table_view.client = self.client + self.sub_pv_table_view.set_data(self.data) + for i in [LivePVHeader.STORED_VALUE, LivePVHeader.STORED_SEVERITY, + LivePVHeader.STORED_STATUS]: + self.sub_pv_table_view.setColumnHidden(i, True) + + self.sub_coll_table_view.client = self.client + self.sub_coll_table_view.set_data(self.data) - # Configure button delegates - self.pv_open_delegate = ButtonDelegate(button_text='open details') - self.sub_pv_table_view.setItemDelegateForColumn(LivePVHeader.OPEN, - self.pv_open_delegate) - self.pv_open_delegate.clicked.connect(self.open_sub_pv_row) - - self.pv_remove_delegate = ButtonDelegate(button_text='remove') - self.sub_pv_table_view.setItemDelegateForColumn(LivePVHeader.REMOVE, - self.pv_remove_delegate) - self.pv_remove_delegate.clicked.connect(self.remove_sub_pv_row) - - self.nest_open_delegate = ButtonDelegate(button_text='open details') - self.sub_coll_table_view.setItemDelegateForColumn( - 3, self.nest_open_delegate - ) - self.nest_open_delegate.clicked.connect(self.open_sub_coll_row) - - self.nest_remove_delegate = ButtonDelegate(button_text='remove') - self.sub_coll_table_view.setItemDelegateForColumn( - 4, self.nest_remove_delegate - ) - self.nest_remove_delegate.clicked.connect(self.remove_sub_coll_row) + self.tree_model = RootTree(base_entry=self.data) + self.tree_view.setModel(self.tree_model) + self.sub_coll_table_view.data_updated.connect(self.tree_model.refresh_tree) + self.sub_pv_table_view.data_updated.connect(self.tree_model.refresh_tree) def _update_title(self): """Set title attribute for access by containing widgets""" @@ -115,24 +103,6 @@ def open_row_details( entry = model.entries[index.row()] self.open_page_slot(entry) - def open_sub_pv_row(self, index: QtCore.QModelIndex) -> None: - self.open_row_details(self.pv_model, index) - - def open_sub_coll_row(self, index: QtCore.QModelIndex) -> None: - self.open_row_details(self.coll_model, index) - - def remove_entry(self, entry: Entry) -> None: - self.data.children.remove(entry) - self.update_model_data() - - def remove_sub_pv_row(self, index: QtCore.QModelIndex) -> None: - entry = self.pv_model.entries[index.row()] - self.remove_entry(entry) - - def remove_sub_coll_row(self, index: QtCore.QModelIndex) -> None: - entry = self.coll_model.entries[index.row()] - self.remove_entry(entry) - def set_rbv_enabled(self, state: int): """Disable RBV line edit if read-only checkbox is enabled""" self.rbv_line_edit.clear() @@ -140,44 +110,11 @@ def set_rbv_enabled(self, state: int): def update_model_data(self): """ - Update the model data. If no models exist, initialize new ones. - If models have already been initialized, + Update the model data. Signal the models to re-read the data """ - # tree model - if self.tree_model is None: - self.tree_model = RootTree(base_entry=self.data) - self.tree_view.setModel(self.tree_model) - else: - self.tree_model.refresh_tree() - - # initialize tables - self.sub_colls = [child for child in self.data.children - if isinstance(child, Collection)] - self.sub_pvs = [child for child in self.data.children - if not isinstance(child, Collection)] - - logger.debug(f"Updating view with {len(self.sub_pvs)} parameters " - f"and {len(self.sub_colls)} collections") - - # PVEntry model - if self.pv_model is None: - self.pv_model = LivePVTableModel(entries=self.sub_pvs, client=self.client) - self.sub_pv_table_view.setModel(self.pv_model) - - # TODO: un-hard code this once there is a better way of managing columns - # Potentially dealing with columns that have moved - for i in [LivePVHeader.STORED_VALUE, LivePVHeader.STORED_SEVERITY, - LivePVHeader.STORED_STATUS]: - self.sub_pv_table_view.setColumnHidden(i, True) - else: - self.pv_model.set_entries(self.sub_pvs) - - # Nestable Model - if self.coll_model is None: - self.coll_model = NestableTableModel(entries=self.sub_colls) - self.sub_coll_table_view.setModel(self.coll_model) - else: - self.coll_model.set_entries(self.sub_colls) + self.tree_model.refresh_tree() + self.sub_pv_table_view.set_data(self.data) + self.sub_coll_table_view.set_data(self.data) def save_collection(self): """Save current collection to database via Client""" @@ -246,5 +183,5 @@ def add_sub_collection(self): def closeEvent(self, a0: QCloseEvent) -> None: logger.debug("Stopping pv_model polling") - self.pv_model.stop_polling(wait_time=5000) + self.sub_pv_table_view._model.stop_polling(wait_time=5000) return super().closeEvent(a0) diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index 76cbca2..6e79a37 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -492,10 +492,6 @@ def set_entries(self, entries: List[Entry]): """ self.layoutAboutToBeChanged.emit() self.entries = entries - self.dataChanged.emit( - self.createIndex(0, 0), - self.createIndex(self.rowCount(), self.columnCount()), - ) self.layoutChanged.emit() def headerData( @@ -538,7 +534,21 @@ def add_entry(self, entry: Entry) -> None: if entry in self.entries or not isinstance(entry, Entry): return + self.layoutAboutToBeChanged.emit() self.entries.append[entry] + self.layoutChanged() + + def remove_row(self, row_index: int) -> None: + self.remove_entry(self.entries[row_index]) + + def remove_entry(self, entry: Entry) -> None: + self.layoutAboutToBeChanged.emit() + try: + self.entries.remove(entry) + except ValueError: + logger.debug(f"Entry of type ({type(entry).__name__})" + "not found in table, could not remove.") + self.layoutChanged() def icon(self, entry: Entry) -> Optional[QtGui.QIcon]: """return icon for this ``entry``""" @@ -587,7 +597,6 @@ def __init__( *args, client: Client, entries: Optional[List[PVEntry]] = None, - open_page_slot: Optional[Callable] = None, poll_period: float = 1.0, **kwargs ) -> None: @@ -595,7 +604,6 @@ def __init__( self.headers = [h.header_name() for h in LivePVHeader] self.client = client - self.open_page_slot = open_page_slot self.poll_period = poll_period self._data_cache = {e.pv_name: None for e in entries} self._poll_thread = None @@ -683,6 +691,14 @@ def set_entries(self, entries: list[PVEntry]) -> None: ) self.layoutChanged.emit() + def remove_entry(self, entry: PVEntry) -> None: + """Remove ``entry`` from the table model""" + super().remove_entry(entry) + self.layoutAboutToBeChanged.emit() + self._data_cache = {e.pv_name: None for e in self.entries} + self._poll_thread.data = self._data_cache + self.layoutChanged.emit() + def index_from_item( self, item: PVEntry, @@ -730,11 +746,9 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: the requested data """ entry: PVEntry = self.entries[index.row()] - - # Special handling for open button delegate - if index.column() == (len(self.headers) - 1): - if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): - return 'click to open' + if isinstance(entry, UUID): + entry = self.client.backend.get_entry(self.entries[index.row()]) + self.entries[index.row()] = entry if index.column() == LivePVHeader.PV_NAME: if role == QtCore.Qt.DecorationRole: @@ -915,6 +929,161 @@ def run(self): time.sleep(max((0, self.poll_period - elapsed))) +class BaseDataTableView(QtWidgets.QTableView): + """ + Base TableView for holding and manipulating an entry / list of entries + TODO: signature docs + """ + # signal indicating the contained has been updated + data_updated: ClassVar[QtCore.Signal] = QtCore.Signal() + _model: Optional[Union[LivePVTableModel, NestableTableModel]] = None + _model_cls: Union[LivePVTableModel, NestableTableModel] = LivePVTableModel + open_column: int = 0 + remove_column: int = 0 + + def __init__( + self, + *args, + client: Optional[Client] = None, + data: Optional[Union[Entry, List[Entry]]] = None, + open_page_slot: Optional[Callable] = None, + **kwargs, + ) -> None: + """need to set open_column, close_column in subclass""" + super().__init__(*args, **kwargs) + self.data = data + self._client = client + self.open_page_slot = open_page_slot + self.sub_entries = [] + + # only these for now, may need an update later + self.setup_ui() + + def setup_ui(self): + """initialize ui elements for this table""" + # set delegates + self.open_delegate = ButtonDelegate(button_text='open details') + self.setItemDelegateForColumn(self.open_column, self.open_delegate) + self.open_delegate.clicked.connect(self.open_row_details) + + self.remove_delegate = ButtonDelegate(button_text='remove') + self.setItemDelegateForColumn(self.remove_column, self.remove_delegate) + self.remove_delegate.clicked.connect(self.remove_row) + + def open_row_details(self, index: QtCore.QModelIndex) -> None: + entry = self._model.entries[index.row()] + self.open_page_slot(entry) + + def remove_row(self, index: QtCore.QModelIndex) -> None: + entry = self._model.entries[index.row()] + self._model.remove_row(index.row()) + + if isinstance(self.data, list): + self.data.remove(entry) + elif isinstance(self.data, Nestable): + self.data.children.remove(entry) + # edit data held by widget + self.data_updated.emit() + + def set_data(self, data: Any): + """Set the data for this view, re-setup ui""" + if not isinstance(data, (list, Nestable)): + raise ValueError( + f"Attempted to set an incompatable data type ({type(data)})" + ) + self.data = data + self.gather_sub_entries() + + if self.client is None: + logger.debug("Client not yet set, cannot initialize model") + return + + if self._model is None: + self._model = self._model_cls( + client=self.client, + entries=self.sub_entries + ) + self.setModel(self._model) + else: + self._model.set_entries(self.sub_entries) + + self.data_updated.emit() + + def gather_sub_entries(self): + raise NotImplementedError + + @property + def client(self): + return self._client + + @client.setter + def client(self, client: Client): + self._set_client(client) + + def _set_client(self, client: Client): + if client is self._client: + return + + if not isinstance(client, Client): + raise ValueError("Provided client is not a superscore Client") + + self._client = client + + +class LivePVTableView(BaseDataTableView): + """ + table widget for LivePVTableModel. Meant to provide a standard, easy-to-use + interface for this table model, with common configuration options exposed + + compatible with list of entries or full entry + - updates entry when changes made + - maintains order for rebuilding of parent collections + Configures delegates, ignoring open page slot if provided + Column manipulation + - show/hide + - re-order + flattening of base data + - handling of readbacks associated with base entries? + - handling of nested nestables + ediable stored fields if desired, updating entry + """ + # TODO: add config args / methods: {show/hide}, poll period, column order? + _model: Optional[LivePVTableModel] + + def __init__(self, *args, **kwargs): + self._model_cls = LivePVTableModel + self.open_column = LivePVHeader.OPEN + self.remove_column = LivePVHeader.REMOVE + super().__init__(*args, **kwargs) + + def gather_sub_entries(self): + if isinstance(self.data, UUID): + self.data = self.client.backend.get_entry(self.data) + + # TODO: gather and fill entries where necessary + if isinstance(self.data, Nestable): + # gather sub_nestables + self.sub_entries = [child for child in self.data.children + if not isinstance(child, Nestable)] + + for i, sub_nest in enumerate(self.sub_entries): + if isinstance(sub_nest, UUID): + new_entry = self._client.backend.get_entry(sub_nest) + self.sub_entries[i] = new_entry + + if isinstance(self.data, (Parameter, Setpoint, Readback)): + self.sub_entries = [self.data] + + @BaseDataTableView.client.setter + def client(self, client: Optional[Client]): + super()._set_client(client) + # reset model poll thread + if self._model is not None: + self._model.stop_polling() + self._model.client = self._client + self._model.start_polling() + + class NestableTableModel(BaseTableEntryModel): # Shows simplified details (created time, description, # pvs, # child colls) # Open details delegate @@ -923,6 +1092,7 @@ class NestableTableModel(BaseTableEntryModel): def __init__( self, *args, + client: Optional[Client] = None, entries: Optional[List[Union[Snapshot, Collection]]] = None, open_page_slot: Optional[Callable] = None, **kwargs @@ -949,11 +1119,6 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: """ entry: Entry = self.entries[index.row()] - # Special handling for open button delegate - if index.column() == (len(self.headers) - 1): - if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): - return 'click to open' - if role != QtCore.Qt.DisplayRole: # table is read only return QtCore.QVariant() @@ -973,6 +1138,28 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: return "Remove" +class NestableTableView(BaseDataTableView): + def __init__(self, *args, **kwargs): + self._model_cls = NestableTableModel + self.open_column = 3 + self.remove_column = 4 + super().__init__(*args, **kwargs) + + def gather_sub_entries(self): + if isinstance(self.data, UUID): + self.data = self.client.backend.get_entry(self.data) + # TODO: gather and fill entries where necessary + if isinstance(self.data, Nestable): + # gather sub_nestables + self.sub_entries = [child for child in self.data.children + if isinstance(child, Nestable)] + + for i, sub_nest in enumerate(self.sub_entries): + if isinstance(sub_nest, UUID): + new_entry = self._client.backend.get_entry(sub_nest) + self.sub_entries[i] = new_entry + + class ButtonDelegate(QtWidgets.QStyledItemDelegate): clicked = QtCore.Signal(QtCore.QModelIndex) From 8451d4b4f082eb4498a98771eb92beb2d9fa5bdc Mon Sep 17 00:00:00 2001 From: tangkong Date: Mon, 30 Sep 2024 14:46:15 -0700 Subject: [PATCH 02/22] ENH: add enum and numeric metadata fields to EpicsData --- superscore/control_layers/_aioca.py | 42 +++++++++++++++++-------- superscore/control_layers/_base_shim.py | 7 +++++ 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/superscore/control_layers/_aioca.py b/superscore/control_layers/_aioca.py index 5273e0b..2778465 100644 --- a/superscore/control_layers/_aioca.py +++ b/superscore/control_layers/_aioca.py @@ -37,12 +37,13 @@ async def get(self, address: str) -> EpicsData: If the caget operation fails for any reason. """ try: - value = await caget(address, format=dbr.FORMAT_TIME) + value_time = await caget(address, format=dbr.FORMAT_TIME) + value_ctrl = await caget(address, format=dbr.FORMAT_CTRL) except CANothing as ex: logger.debug(f"CA get failed {ex.__repr__()}") raise CommunicationError(f'CA get failed for {ex}') - return self.value_to_epics_data(value) + return self.value_to_epics_data(value_time, value_ctrl) async def put(self, address: str, value: Any) -> None: """ @@ -80,30 +81,45 @@ def monitor(self, address: str, callback: Callable) -> None: camonitor(address, callback) @staticmethod - def value_to_epics_data(value: AugmentedValue) -> EpicsData: + def value_to_epics_data( + value_time: AugmentedValue, value_ctrl: AugmentedValue + ) -> EpicsData: """ Creates an EpicsData instance from an aioca provided AugmentedValue - Assumes the augmented value was collected with FORMAT_TIME qualifier. - AugmentedValue subclasses primitive datatypes, so they can be used as - data directly. + Assumes the AugmentedValue's are collected with FORMAT_TIME and + FORMAT_CTRL qualifier. AugmentedValue subclasses primitive datatypes, + so they can be used as data directly. Parameters ---------- - value : AugmentedValue - The value to repackage + value_time : AugmentedValue + A value collected with dbr.FORMAT_TIME + value_ctrl : AugmentedValue + A value collected with dbr.FORMAT_CTRL Returns ------- EpicsData The filled EpicsData instance """ - severity = Severity(value.severity) - status = Status(value.status) - timestamp = value.timestamp + severity = Severity(value_time.severity) + status = Status(value_time.status) + timestamp = value_time.timestamp + + units = getattr(value_ctrl, "units", None) + precision = getattr(value_ctrl, "precision", None) + upper_ctrl_limit = getattr(value_ctrl, "upper_ctrl_limit", None) + lower_ctrl_limit = getattr(value_ctrl, "lower_ctrl_limit", None) + enums = getattr(value_ctrl, "enums", None) return EpicsData( - data=value, + data=value_time, status=status, severity=severity, - timestamp=timestamp + timestamp=timestamp, + units=units, + precision=precision, + upper_ctrl_limit=upper_ctrl_limit, + lower_ctrl_limit=lower_ctrl_limit, + enums=enums ) diff --git a/superscore/control_layers/_base_shim.py b/superscore/control_layers/_base_shim.py index edee5fb..bc47527 100644 --- a/superscore/control_layers/_base_shim.py +++ b/superscore/control_layers/_base_shim.py @@ -30,3 +30,10 @@ class EpicsData: status: Severity = Status.UDF severity: Status = Severity.INVALID timestamp: datetime = field(default_factory=utcnow) + + # Extra metadata + units: Optional[str] = None + precision: Optional[int] = None + upper_ctrl_limit: Optional[float] = None + lower_ctrl_limit: Optional[float] = None + enums: Optional[list[str]] = None From 32a1a8c6fd901a8174f930bb7e52ce8d979b6e47 Mon Sep 17 00:00:00 2001 From: tangkong Date: Mon, 30 Sep 2024 14:50:49 -0700 Subject: [PATCH 03/22] ENH: add ValueDelegate for Edit widgets based on datatype, enable editable flags, improve enum handling --- superscore/widgets/views.py | 167 +++++++++++++++++++++++++++++++++--- 1 file changed, 157 insertions(+), 10 deletions(-) diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index 6e79a37..1db6d94 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -20,7 +20,7 @@ from superscore.client import Client from superscore.control_layers import EpicsData from superscore.model import (Collection, Entry, Nestable, Parameter, Readback, - Root, Setpoint, Snapshot) + Root, Setpoint, Severity, Snapshot, Status) from superscore.qt_helpers import QDataclassBridge from superscore.widgets import ICON_MAP @@ -30,6 +30,11 @@ PVEntry = Union[Parameter, Setpoint, Readback] +class CustRoles(IntEnum): + DisplayTypeRole = QtCore.Qt.UserRole + EpicsDataRole = auto() + + class EntryItem: """Node representing one Entry""" _bridge_cache: ClassVar[ @@ -343,7 +348,6 @@ def index_from_item(self, item: EntryItem) -> QtCore.QModelIndex: def parent(self, index: QtCore.QModelIndex) -> QtCore.QModelIndex: """ - Returns the parent of the given model item. Parameters ---------- @@ -440,7 +444,7 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: if role == QtCore.Qt.DisplayRole: return item.data(index.column()) - if role == QtCore.Qt.UserRole: + if role == CustRoles.DisplayTypeRole: return item if role == QtCore.Qt.DecorationRole and index.column() == 0: @@ -468,6 +472,7 @@ class BaseTableEntryModel(QtCore.QAbstractTableModel): """ entries: List[Entry] headers: List[str] + _editable_cols: Dict[int, bool] = {} def __init__( self, @@ -510,6 +515,10 @@ def headerData( if orientation == QtCore.Qt.Horizontal: return self.headers[section] + def set_editable(self, col_index: int, editable: bool) -> None: + """If a column is allowed to be editable, set it as editable""" + self._editable_cols[col_index] = editable + def flags(self, index: QtCore.QModelIndex) -> QtCore.Qt.ItemFlag: """ Returns the item flags for the given ``index``. The returned @@ -525,8 +534,8 @@ def flags(self, index: QtCore.QModelIndex) -> QtCore.Qt.ItemFlag: QtCore.Qt.ItemFlag the ItemFlag corresponding to the cell """ - if (index.column() == len(self.headers) - 1): - return QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEnabled + if self._editable_cols[index.column()]: + return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable else: return QtCore.Qt.ItemIsEnabled @@ -536,7 +545,7 @@ def add_entry(self, entry: Entry) -> None: self.layoutAboutToBeChanged.emit() self.entries.append[entry] - self.layoutChanged() + self.layoutChanged.emit() def remove_row(self, row_index: int) -> None: self.remove_entry(self.entries[row_index]) @@ -548,7 +557,7 @@ def remove_entry(self, entry: Entry) -> None: except ValueError: logger.debug(f"Entry of type ({type(entry).__name__})" "not found in table, could not remove.") - self.layoutChanged() + self.layoutChanged.emit() def icon(self, entry: Entry) -> Optional[QtGui.QIcon]: """return icon for this ``entry``""" @@ -558,6 +567,14 @@ def icon(self, entry: Entry) -> Optional[QtGui.QIcon]: return qta.icon(icon_id) +class DisplayType(Enum): + """type of data displayed in tables""" + PV_NAME = auto() + STATUS = auto() + SEVERITY = auto() + EPICS_DATA = auto() + + class LivePVHeader(IntEnum): """ Enum for more readable header names. Underscores will be replaced with spaces @@ -591,6 +608,12 @@ class LivePVTableModel(BaseTableEntryModel): headers: List[str] _data_cache: Dict[str, EpicsData] _poll_thread: Optional[_PVPollThread] + _header_to_field: Dict[LivePVHeader, str] = { + LivePVHeader.PV_NAME: 'pv_name', + LivePVHeader.STORED_VALUE: 'data', + LivePVHeader.STORED_STATUS: 'status', + LivePVHeader.STORED_SEVERITY: 'severity', + } def __init__( self, @@ -601,8 +624,12 @@ def __init__( **kwargs ) -> None: super().__init__(*args, entries=entries, **kwargs) - self.headers = [h.header_name() for h in LivePVHeader] + + self._editable_cols = {h.value: False for h in LivePVHeader} + self._editable_cols[LivePVHeader.OPEN] = True + self._editable_cols[LivePVHeader.REMOVE] = True + self.client = client self.poll_period = poll_period self._data_cache = {e.pv_name: None for e in entries} @@ -733,6 +760,9 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: Returns the data stored under the given role for the item referred to by the index. + UserRole provides data necessary to generate an edit delegate for the + cell based on the data-type + Parameters ---------- index : QtCore.QModelIndex @@ -756,13 +786,29 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: elif role == QtCore.Qt.DisplayRole: name_text = getattr(entry, 'pv_name') return name_text + elif role == CustRoles.DisplayTypeRole: + return DisplayType.PV_NAME - if role not in (QtCore.Qt.DisplayRole, QtCore.Qt.BackgroundRole): + if role not in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole, + QtCore.Qt.BackgroundRole, CustRoles.DisplayTypeRole, + CustRoles.EpicsDataRole): # Other parts of the table are read only return QtCore.QVariant() if index.column() == LivePVHeader.STORED_VALUE: - return getattr(entry, 'data', '--') + cache_data = self.get_cache_data(entry.pv_name) + if role == CustRoles.DisplayTypeRole: + return DisplayType.EPICS_DATA + elif role == CustRoles.EpicsDataRole: + return cache_data + stored_data = getattr(entry, 'data', None) + if stored_data is None: + return '--' + # do some data handling (currently only for enums) + if isinstance(cache_data, EpicsData): + if cache_data.enums and isinstance(stored_data, int): + return cache_data.enums[stored_data] + return stored_data elif index.column() == LivePVHeader.LIVE_VALUE: live_value = self._get_live_data_field(entry, 'data') stored_data = getattr(entry, 'data', None) @@ -773,11 +819,15 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: elif index.column() == LivePVHeader.TIMESTAMP: return entry.creation_time.strftime('%Y/%m/%d %H:%M') elif index.column() == LivePVHeader.STORED_STATUS: + if role == CustRoles.DisplayTypeRole: + return DisplayType.STATUS status = getattr(entry, 'status', '--') return getattr(status, 'name', status) elif index.column() == LivePVHeader.LIVE_STATUS: return self._get_live_data_field(entry, 'status') elif index.column() == LivePVHeader.STORED_SEVERITY: + if role == CustRoles.DisplayTypeRole: + return DisplayType.SEVERITY severity = getattr(entry, 'severity', '--') return getattr(severity, 'name', severity) elif index.column() == LivePVHeader.LIVE_SEVERITY: @@ -790,6 +840,19 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: # if nothing is found, return invalid QVariant return QtCore.QVariant() + def setData(self, index: QtCore.QModelIndex, value: Any, role: int) -> bool: + """Set data""" + entry = self.entries[index.row()] + header_col = LivePVHeader(index.column()) + try: + print("setData", index, value, role) + setattr(entry, self._header_to_field[header_col], value) + return True + except Exception as exc: + logger.error(f"Failed to set data ({value}) ->" + f"({index.row()}, {index.column()}): {exc}") + return False + def _get_live_data_field(self, entry: PVEntry, field: str) -> Any: """ Helper to get field from data cache @@ -814,6 +877,8 @@ def _get_live_data_field(self, entry: PVEntry, field: str) -> Any: data_field = getattr(live_data, field) if isinstance(data_field, Enum): return str(getattr(data_field, 'name', data_field)) + elif live_data.enums and field == 'data': + return live_data.enums[live_data.data] else: return data_field @@ -1056,6 +1121,11 @@ def __init__(self, *args, **kwargs): self.remove_column = LivePVHeader.REMOVE super().__init__(*args, **kwargs) + self.value_delegate = ValueDelegate() + for col in [LivePVHeader.PV_NAME, LivePVHeader.STORED_VALUE, + LivePVHeader.STORED_STATUS, LivePVHeader.STORED_SEVERITY]: + self.setItemDelegateForColumn(col, self.value_delegate) + def gather_sub_entries(self): if isinstance(self.data, UUID): self.data = self.client.backend.get_entry(self.data) @@ -1098,6 +1168,8 @@ def __init__( **kwargs ) -> None: self.open_page_slot = open_page_slot + self._editable_cols = {ind: False for ind + in range(len(self.headers))} super().__init__(*args, entries=entries, **kwargs) def data(self, index: QtCore.QModelIndex, role: int) -> Any: @@ -1186,3 +1258,78 @@ def updateEditorGeometry( index: QtCore.QModelIndex ) -> None: return editor.setGeometry(option.rect) + + +class ValueDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def createEditor( + self, + parent: QtWidgets.QWidget, + option, + index: QtCore.QModelIndex + ) -> QtWidgets.QWidget: + dtype: DisplayType = index.model().data( + index, role=CustRoles.DisplayTypeRole + ) + data_val = index.model().data(index, role=QtCore.Qt.DisplayRole) + if dtype == DisplayType.PV_NAME: + widget = QtWidgets.QLineEdit(data_val, parent) + elif dtype == DisplayType.STATUS: + widget = QtWidgets.QComboBox(parent) + widget.addItems([sev.name for sev in Severity]) + elif dtype == DisplayType.STATUS: + widget = QtWidgets.QComboBox(parent) + widget.addItems([sta.name for sta in Status]) + elif dtype == DisplayType.EPICS_DATA: + # need to fetch data for this PV, not stored data + data_val: EpicsData = index.model().data( + index, role=CustRoles.EpicsDataRole + ) + if isinstance(data_val.data, str): + widget = QtWidgets.QLineEdit(data_val.data, parent) + elif data_val.enums: # Catch enums before numerics, enums are ints + widget = QtWidgets.QComboBox(parent) + widget.addItems(data_val.enums) + widget.setCurrentIndex(data_val.data) + elif isinstance(data_val.data, int): + widget = QtWidgets.QSpinBox(parent) + widget.setValue(data_val.data) + widget.setMaximum(data_val.upper_ctrl_limit) + widget.setMinimum(data_val.lower_ctrl_limit) + elif isinstance(data_val.data, float): + widget = QtWidgets.QDoubleSpinBox(parent) + widget.setValue(data_val.data) + widget.setMaximum(data_val.upper_ctrl_limit) + widget.setMinimum(data_val.lower_ctrl_limit) + widget.setDecimals(data_val.precision) + else: + logger.debug(f"datatype ({dtype}) incompatible with supported edit " + f"widgets: ({data_val})") + return + return widget + + def setModelData( + self, + editor: QtWidgets.QWidget, + model: QtCore.QAbstractItemModel, + index: QtCore.QModelIndex + ) -> None: + if isinstance(editor, QtWidgets.QAbstractSpinBox): + val = editor.value() + elif isinstance(editor, QtWidgets.QLineEdit): + val = editor.text() + elif isinstance(editor, QtWidgets.QComboBox): + val = editor.currentIndex() + else: + return + model.setData(index, val, QtCore.Qt.EditRole) + + def updateEditorGeometry( + self, + editor: QtWidgets.QWidget, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex + ) -> None: + return editor.setGeometry(option.rect) From 6a25bede48fe55817192226f9abd2aa0d9f7ed26 Mon Sep 17 00:00:00 2001 From: tangkong Date: Mon, 30 Sep 2024 16:30:28 -0700 Subject: [PATCH 04/22] BUG: fix enum handling for is_close highlighting --- superscore/tests/test_views.py | 5 +++-- superscore/widgets/views.py | 23 ++++++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/superscore/tests/test_views.py b/superscore/tests/test_views.py index ea13a54..73055b5 100644 --- a/superscore/tests/test_views.py +++ b/superscore/tests/test_views.py @@ -5,6 +5,7 @@ from qtpy import QtCore from superscore.client import Client +from superscore.control_layers import EpicsData from superscore.model import Parameter from superscore.widgets.views import LivePVTableModel @@ -22,7 +23,7 @@ def pv_poll_model( ) # Make sure we never actually call EPICS - model.client.cl.get = MagicMock(return_value=1) + model.client.cl.get = MagicMock(return_value=EpicsData(1)) qtbot.wait_until(lambda: model._poll_thread.running) yield model @@ -42,7 +43,7 @@ def test_pvmodel_update(pv_poll_model: LivePVTableModel, qtbot: QtBot): assert pv_poll_model._data_cache # make the mock cl return a new value - pv_poll_model.client.cl.get = MagicMock(return_value=3) + pv_poll_model.client.cl.get = MagicMock(return_value=EpicsData(3)) qtbot.wait_signal(pv_poll_model.dataChanged) diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index 1db6d94..f0a9d71 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -812,8 +812,9 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: elif index.column() == LivePVHeader.LIVE_VALUE: live_value = self._get_live_data_field(entry, 'data') stored_data = getattr(entry, 'data', None) - is_close = self.is_close(live_value, stored_data) - if stored_data and role == QtCore.Qt.BackgroundRole and not is_close: + is_close = self.is_close(entry, stored_data) + if ((stored_data is not None) and role == QtCore.Qt.BackgroundRole + and not is_close): return QtGui.QColor('red') return str(live_value) elif index.column() == LivePVHeader.TIMESTAMP: @@ -845,7 +846,6 @@ def setData(self, index: QtCore.QModelIndex, value: Any, role: int) -> bool: entry = self.entries[index.row()] header_col = LivePVHeader(index.column()) try: - print("setData", index, value, role) setattr(entry, self._header_to_field[header_col], value) return True except Exception as exc: @@ -882,15 +882,24 @@ def _get_live_data_field(self, entry: PVEntry, field: str) -> Any: else: return data_field - def is_close(self, l_data, r_data) -> bool: + def is_close(self, entry: PVEntry, data: Any) -> bool: """ - Returns True if ``l_data`` is close to ``r_data``, False otherwise. - Intended for use with numeric values. + Determines if ``data`` is close to the value in the controls system at + ``entry``. Returns True if the values are close, False otherwise. """ + e_data = self.get_cache_data(entry.pv_name) + if hasattr(e_data, "enums") and isinstance(data, int): + # Unify enum representation + r_data = e_data.enums[data] + l_data = e_data.enums[e_data.data] + else: + r_data = data + l_data = e_data.data + try: return np.isclose(l_data, r_data) except TypeError: - return False + return l_data == r_data def get_cache_data(self, pv_name: str) -> EpicsData: """ From eb8249bbb9a19356ae163a5eac67d85ba4454c4a Mon Sep 17 00:00:00 2001 From: tangkong Date: Thu, 3 Oct 2024 14:29:18 -0700 Subject: [PATCH 05/22] MNT: fix edge cases while data is fetching --- superscore/widgets/views.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index f0a9d71..4aac4cf 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -602,9 +602,6 @@ class LivePVTableModel(BaseTableEntryModel): # Takes PV-entries # shows live details (current PV status, severity) # shows setpoints (can be blank) - # TO-DO: - # open details delegate - # methods for hide un-needed rows (user interaction?) headers: List[str] _data_cache: Dict[str, EpicsData] _poll_thread: Optional[_PVPollThread] @@ -783,7 +780,7 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: if index.column() == LivePVHeader.PV_NAME: if role == QtCore.Qt.DecorationRole: return self.icon(entry) - elif role == QtCore.Qt.DisplayRole: + elif role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): name_text = getattr(entry, 'pv_name') return name_text elif role == CustRoles.DisplayTypeRole: @@ -796,15 +793,16 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: return QtCore.QVariant() if index.column() == LivePVHeader.STORED_VALUE: - cache_data = self.get_cache_data(entry.pv_name) if role == CustRoles.DisplayTypeRole: return DisplayType.EPICS_DATA - elif role == CustRoles.EpicsDataRole: + cache_data = self.get_cache_data(entry.pv_name) + if role == CustRoles.EpicsDataRole: return cache_data + stored_data = getattr(entry, 'data', None) if stored_data is None: return '--' - # do some data handling (currently only for enums) + # do some enum data handling if isinstance(cache_data, EpicsData): if cache_data.enums and isinstance(stored_data, int): return cache_data.enums[stored_data] @@ -888,6 +886,10 @@ def is_close(self, entry: PVEntry, data: Any) -> bool: ``entry``. Returns True if the values are close, False otherwise. """ e_data = self.get_cache_data(entry.pv_name) + if not isinstance(e_data, EpicsData): + # data still fetching, don't compare + return + if hasattr(e_data, "enums") and isinstance(data, int): # Unify enum representation r_data = e_data.enums[data] @@ -901,7 +903,7 @@ def is_close(self, entry: PVEntry, data: Any) -> bool: except TypeError: return l_data == r_data - def get_cache_data(self, pv_name: str) -> EpicsData: + def get_cache_data(self, pv_name: str) -> Union[EpicsData, str]: """ Get data from cache if possible. If missing from cache, add pv_name for the polling thread to update. @@ -1113,8 +1115,9 @@ class LivePVTableView(BaseDataTableView): - updates entry when changes made - maintains order for rebuilding of parent collections Configures delegates, ignoring open page slot if provided + + TO-DO: Column manipulation - - show/hide - re-order flattening of base data - handling of readbacks associated with base entries? @@ -1296,6 +1299,9 @@ def createEditor( data_val: EpicsData = index.model().data( index, role=CustRoles.EpicsDataRole ) + if isinstance(data_val, str): + # not yet initialized, no-op + return if isinstance(data_val.data, str): widget = QtWidgets.QLineEdit(data_val.data, parent) elif data_val.enums: # Catch enums before numerics, enums are ints From ad74dcf3c880ca1817972ed0f33db38df4097645 Mon Sep 17 00:00:00 2001 From: tangkong Date: Thu, 3 Oct 2024 14:56:37 -0700 Subject: [PATCH 06/22] Add poll_period to LivePVTableView kwargs, pass to underlying model. Add set_editable method to views --- superscore/widgets/views.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index 4aac4cf..b9818eb 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -1008,7 +1008,6 @@ def run(self): class BaseDataTableView(QtWidgets.QTableView): """ Base TableView for holding and manipulating an entry / list of entries - TODO: signature docs """ # signal indicating the contained has been updated data_updated: ClassVar[QtCore.Signal] = QtCore.Signal() @@ -1031,12 +1030,13 @@ def __init__( self._client = client self.open_page_slot = open_page_slot self.sub_entries = [] + self.model_kwargs = {} # only these for now, may need an update later self.setup_ui() def setup_ui(self): - """initialize ui elements for this table""" + """initialize basic ui elements for this table""" # set delegates self.open_delegate = ButtonDelegate(button_text='open details') self.setItemDelegateForColumn(self.open_column, self.open_delegate) @@ -1047,8 +1047,10 @@ def setup_ui(self): self.remove_delegate.clicked.connect(self.remove_row) def open_row_details(self, index: QtCore.QModelIndex) -> None: - entry = self._model.entries[index.row()] - self.open_page_slot(entry) + """slot for opening row details page""" + if self.open_page_slot: + entry = self._model.entries[index.row()] + self.open_page_slot(entry) def remove_row(self, index: QtCore.QModelIndex) -> None: entry = self._model.entries[index.row()] @@ -1077,7 +1079,8 @@ def set_data(self, data: Any): if self._model is None: self._model = self._model_cls( client=self.client, - entries=self.sub_entries + entries=self.sub_entries, + **self.model_kwargs ) self.setModel(self._model) else: @@ -1086,6 +1089,10 @@ def set_data(self, data: Any): self.data_updated.emit() def gather_sub_entries(self): + """ + Gather entries relevant to the contained model + and assign to self.sub_entries. This must be implemented in a subclass. + """ raise NotImplementedError @property @@ -1105,6 +1112,11 @@ def _set_client(self, client: Client): self._client = client + def set_editable(self, column: int, is_editable: bool) -> None: + if not self._model: + return + self._model.set_editable(column, is_editable) + class LivePVTableView(BaseDataTableView): """ @@ -1124,15 +1136,16 @@ class LivePVTableView(BaseDataTableView): - handling of nested nestables ediable stored fields if desired, updating entry """ - # TODO: add config args / methods: {show/hide}, poll period, column order? _model: Optional[LivePVTableModel] - def __init__(self, *args, **kwargs): + def __init__(self, *args, poll_period: float = 1.0, **kwargs): self._model_cls = LivePVTableModel self.open_column = LivePVHeader.OPEN self.remove_column = LivePVHeader.REMOVE super().__init__(*args, **kwargs) + self.model_kwargs['poll_period'] = poll_period + self.value_delegate = ValueDelegate() for col in [LivePVHeader.PV_NAME, LivePVHeader.STORED_VALUE, LivePVHeader.STORED_STATUS, LivePVHeader.STORED_SEVERITY]: @@ -1142,7 +1155,6 @@ def gather_sub_entries(self): if isinstance(self.data, UUID): self.data = self.client.backend.get_entry(self.data) - # TODO: gather and fill entries where necessary if isinstance(self.data, Nestable): # gather sub_nestables self.sub_entries = [child for child in self.data.children @@ -1153,7 +1165,7 @@ def gather_sub_entries(self): new_entry = self._client.backend.get_entry(sub_nest) self.sub_entries[i] = new_entry - if isinstance(self.data, (Parameter, Setpoint, Readback)): + elif isinstance(self.data, (Parameter, Setpoint, Readback)): self.sub_entries = [self.data] @BaseDataTableView.client.setter From 996dc407ae3a512919ae1dc0162370628a8ca9f6 Mon Sep 17 00:00:00 2001 From: tangkong Date: Thu, 3 Oct 2024 15:28:43 -0700 Subject: [PATCH 07/22] MNT: generalize HeaderEnum, BaseTableModel.setData. Setup NestableTableModel to be editable similar to LivePVTableModel --- superscore/widgets/views.py | 83 +++++++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 31 deletions(-) diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index b9818eb..807533c 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -453,6 +453,18 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: return None +class HeaderEnum(IntEnum): + """ + Enum for more readable header names. Underscores will be replaced with spaces + """ + def header_name(self) -> str: + return self.name.title().replace('_', ' ') + + @classmethod + def from_header_name(cls, name: str) -> HeaderEnum: + return cls[name.upper().replace(' ', '_')] + + class BaseTableEntryModel(QtCore.QAbstractTableModel): """ Common methods for table model that holds onto entries. @@ -472,7 +484,9 @@ class BaseTableEntryModel(QtCore.QAbstractTableModel): """ entries: List[Entry] headers: List[str] + header_enum: HeaderEnum _editable_cols: Dict[int, bool] = {} + _header_to_field: Dict[HeaderEnum: str] def __init__( self, @@ -517,8 +531,26 @@ def headerData( def set_editable(self, col_index: int, editable: bool) -> None: """If a column is allowed to be editable, set it as editable""" + if col_index not in self._editable_cols: + return self._editable_cols[col_index] = editable + def setData(self, index: QtCore.QModelIndex, value: Any, role: int) -> bool: + """Set data""" + entry = self.entries[index.row()] + header_col = self.header_enum(index.column()) + self.layoutAboutToBeChanged.emit() + try: + setattr(entry, self._header_to_field[header_col], value) + success = True + except Exception as exc: + logger.error(f"Failed to set data ({value}) ->" + f"({index.row()}, {index.column()}): {exc}") + success = False + + self.layoutChanged.emit() + return success + def flags(self, index: QtCore.QModelIndex) -> QtCore.Qt.ItemFlag: """ Returns the item flags for the given ``index``. The returned @@ -575,10 +607,7 @@ class DisplayType(Enum): EPICS_DATA = auto() -class LivePVHeader(IntEnum): - """ - Enum for more readable header names. Underscores will be replaced with spaces - """ +class LivePVHeader(HeaderEnum): PV_NAME = 0 STORED_VALUE = auto() LIVE_VALUE = auto() @@ -590,13 +619,6 @@ class LivePVHeader(IntEnum): OPEN = auto() REMOVE = auto() - def header_name(self) -> str: - return self.name.title().replace('_', ' ') - - @classmethod - def from_header_name(cls, name: str) -> LivePVHeader: - return LivePVHeader[name.upper().replace(' ', '_')] - class LivePVTableModel(BaseTableEntryModel): # Takes PV-entries @@ -621,7 +643,8 @@ def __init__( **kwargs ) -> None: super().__init__(*args, entries=entries, **kwargs) - self.headers = [h.header_name() for h in LivePVHeader] + self.header_enum = LivePVHeader + self.headers = [h.header_name() for h in self.header_enum] self._editable_cols = {h.value: False for h in LivePVHeader} self._editable_cols[LivePVHeader.OPEN] = True @@ -839,18 +862,6 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: # if nothing is found, return invalid QVariant return QtCore.QVariant() - def setData(self, index: QtCore.QModelIndex, value: Any, role: int) -> bool: - """Set data""" - entry = self.entries[index.row()] - header_col = LivePVHeader(index.column()) - try: - setattr(entry, self._header_to_field[header_col], value) - return True - except Exception as exc: - logger.error(f"Failed to set data ({value}) ->" - f"({index.row()}, {index.column()}): {exc}") - return False - def _get_live_data_field(self, entry: PVEntry, field: str) -> Any: """ Helper to get field from data cache @@ -1178,23 +1189,34 @@ def client(self, client: Optional[Client]): self._model.start_polling() +class NestableHeader(HeaderEnum): + NAME = 0 + DESCRIPTION = auto() + CREATED = auto() + OPEN = auto() + REMOVE = auto() + + class NestableTableModel(BaseTableEntryModel): # Shows simplified details (created time, description, # pvs, # child colls) # Open details delegate - headers: List[str] = ['Name', 'Description', 'Created', 'Open', 'Remove'] + headers: List[str] + _header_to_field: Dict[NestableHeader, str] = { + NestableHeader.NAME: 'title', + NestableHeader.DESCRIPTION: 'description', + } def __init__( self, *args, client: Optional[Client] = None, entries: Optional[List[Union[Snapshot, Collection]]] = None, - open_page_slot: Optional[Callable] = None, **kwargs ) -> None: - self.open_page_slot = open_page_slot - self._editable_cols = {ind: False for ind - in range(len(self.headers))} super().__init__(*args, entries=entries, **kwargs) + self.header_enum = NestableHeader + self.headers = [h.header_name() for h in NestableHeader] + self._editable_cols = {h.value: False for h in NestableHeader} def data(self, index: QtCore.QModelIndex, role: int) -> Any: """ @@ -1215,8 +1237,7 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: """ entry: Entry = self.entries[index.row()] - if role != QtCore.Qt.DisplayRole: - # table is read only + if role not in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): return QtCore.QVariant() if index.column() == 0: # name column From 2bf3f35ac4ddfda66a8197852dd715259b73fa09 Mon Sep 17 00:00:00 2001 From: tangkong Date: Thu, 3 Oct 2024 15:54:19 -0700 Subject: [PATCH 08/22] TST: add quick test_coll_builder_edit to verify edit functionality --- superscore/tests/conftest.py | 2 +- superscore/tests/test_page.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/superscore/tests/conftest.py b/superscore/tests/conftest.py index 689dd8f..d86873d 100644 --- a/superscore/tests/conftest.py +++ b/superscore/tests/conftest.py @@ -772,7 +772,7 @@ def sample_client( filestore_backend: FilestoreBackend, dummy_cl: ControlLayer ) -> Client: - """Return a client with actula data, but no communication capabilities""" + """Return a client with actual data, but no communication capabilities""" client = Client(backend=filestore_backend) client.cl = dummy_cl diff --git a/superscore/tests/test_page.py b/superscore/tests/test_page.py index f812050..0ef63ac 100644 --- a/superscore/tests/test_page.py +++ b/superscore/tests/test_page.py @@ -2,6 +2,7 @@ import pytest from pytestqt.qtbot import QtBot +from qtpy import QtCore from superscore.client import Client from superscore.model import Collection, Parameter @@ -86,3 +87,30 @@ def test_coll_builder_add(collection_builder_page: CollectionBuilderPage): page.add_collection_button.clicked.emit() assert added_collection is page.data.children[1] assert page.sub_coll_table_view._model.rowCount() == 1 + + +def test_coll_builder_edit( + collection_builder_page: CollectionBuilderPage, + qtbot: QtBot +): + page = collection_builder_page + + page.pv_line_edit.setText("THIS:PV") + page.add_pvs_button.clicked.emit() + + pv_model = page.sub_pv_table_view.model() + qtbot.waitUntil(lambda: pv_model.rowCount() == 1) + assert "THIS:PV" in page.data.children[0].pv_name + + first_index = pv_model.createIndex(0, 0) + pv_model.setData(first_index, "NEW:VP", role=QtCore.Qt.EditRole) + + assert "NEW:VP" in page.data.children[0].pv_name + + page.add_collection_button.clicked.emit() + + coll_model = page.sub_coll_table_view.model() + qtbot.waitUntil(lambda: coll_model.rowCount() == 1) + + coll_model.setData(first_index, 'anothername', role=QtCore.Qt.EditRole) + qtbot.waitUntil(lambda: "anothername" in page.data.children[1].title) From 3ff590305ce4ba47f32f4a7f36edce61b3ec2ef7 Mon Sep 17 00:00:00 2001 From: tangkong Date: Thu, 3 Oct 2024 16:06:06 -0700 Subject: [PATCH 09/22] DOC: pre-release notes --- .../90-enh_pv_nest_views.rst | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 docs/source/upcoming_release_notes/90-enh_pv_nest_views.rst diff --git a/docs/source/upcoming_release_notes/90-enh_pv_nest_views.rst b/docs/source/upcoming_release_notes/90-enh_pv_nest_views.rst new file mode 100644 index 0000000..3192228 --- /dev/null +++ b/docs/source/upcoming_release_notes/90-enh_pv_nest_views.rst @@ -0,0 +1,24 @@ +90 enh_pv_nest_views +#################### + +API Breaks +---------- +- N/A + +Features +-------- +- Extends `EpicsData` fields to include controls metadata +- Support editing for LivePVTableModel and NestableModel +- Adds `ValueDelegate`, which provides an edit delegate based on the datatype of the cell + +Bugfixes +-------- +- N/A + +Maintenance +----------- +- Refactors common models to come with their own views, to improve user friendliness + +Contributors +------------ +- tangkong From 72d4d48d8bcabf5972ec31a52b95a3711908c982 Mon Sep 17 00:00:00 2001 From: tangkong Date: Mon, 7 Oct 2024 16:46:40 -0700 Subject: [PATCH 10/22] BUG: allow BaseTableEntryModel to not specify _editable_cols --- superscore/widgets/views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index 807533c..5af084a 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -566,6 +566,9 @@ def flags(self, index: QtCore.QModelIndex) -> QtCore.Qt.ItemFlag: QtCore.Qt.ItemFlag the ItemFlag corresponding to the cell """ + if index.column() not in self._editable_cols: + return + if self._editable_cols[index.column()]: return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable else: From 891849f2a9a5c3d9e07a34424137c88220395746 Mon Sep 17 00:00:00 2001 From: tangkong Date: Mon, 7 Oct 2024 16:48:47 -0700 Subject: [PATCH 11/22] BUG: actually fix the bug --- superscore/widgets/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index 5af084a..90a3b70 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -567,7 +567,7 @@ def flags(self, index: QtCore.QModelIndex) -> QtCore.Qt.ItemFlag: the ItemFlag corresponding to the cell """ if index.column() not in self._editable_cols: - return + return QtCore.Qt.ItemIsEnabled if self._editable_cols[index.column()]: return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable From 015f73729cfb80488db00b22434f5abe7b7b7253 Mon Sep 17 00:00:00 2001 From: tangkong Date: Wed, 9 Oct 2024 16:37:11 -0700 Subject: [PATCH 12/22] MNT: fix uuid filling in BaseDataTableView.gather_sub_entries, add _button_cols to keep track of cols to skip setData on --- superscore/widgets/views.py | 49 +++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index 90a3b70..4b60349 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -19,6 +19,7 @@ from superscore.backends.core import SearchTerm from superscore.client import Client from superscore.control_layers import EpicsData +from superscore.errors import EntryNotFoundError from superscore.model import (Collection, Entry, Nestable, Parameter, Readback, Root, Setpoint, Severity, Snapshot, Status) from superscore.qt_helpers import QDataclassBridge @@ -486,7 +487,8 @@ class BaseTableEntryModel(QtCore.QAbstractTableModel): headers: List[str] header_enum: HeaderEnum _editable_cols: Dict[int, bool] = {} - _header_to_field: Dict[HeaderEnum: str] + _button_cols: List[HeaderEnum] + _header_to_field: Dict[HeaderEnum, str] def __init__( self, @@ -540,6 +542,10 @@ def setData(self, index: QtCore.QModelIndex, value: Any, role: int) -> bool: entry = self.entries[index.row()] header_col = self.header_enum(index.column()) self.layoutAboutToBeChanged.emit() + if header_col in self._button_cols: + # button columns do not actually set data, no-op + return True + try: setattr(entry, self._header_to_field[header_col], value) success = True @@ -630,6 +636,7 @@ class LivePVTableModel(BaseTableEntryModel): headers: List[str] _data_cache: Dict[str, EpicsData] _poll_thread: Optional[_PVPollThread] + _button_cols: List[LivePVHeader] = [LivePVHeader.OPEN, LivePVHeader.REMOVE] _header_to_field: Dict[LivePVHeader, str] = { LivePVHeader.PV_NAME: 'pv_name', LivePVHeader.STORED_VALUE: 'data', @@ -647,7 +654,7 @@ def __init__( ) -> None: super().__init__(*args, entries=entries, **kwargs) self.header_enum = LivePVHeader - self.headers = [h.header_name() for h in self.header_enum] + self.headers = [h.header_name() for h in LivePVHeader] self._editable_cols = {h.value: False for h in LivePVHeader} self._editable_cols[LivePVHeader.OPEN] = True @@ -1171,13 +1178,19 @@ def gather_sub_entries(self): if isinstance(self.data, Nestable): # gather sub_nestables - self.sub_entries = [child for child in self.data.children - if not isinstance(child, Nestable)] + self.sub_entries = [] + for child in self.data.children: + if isinstance(child, UUID): + filled_child = self._client.backend.get_entry(child) + else: + filled_child = child + + if filled_child is None: + raise EntryNotFoundError(f"{child} not found in backend, " + "cannot fill with real data") - for i, sub_nest in enumerate(self.sub_entries): - if isinstance(sub_nest, UUID): - new_entry = self._client.backend.get_entry(sub_nest) - self.sub_entries[i] = new_entry + if not isinstance(child, Nestable) and isinstance(child, Entry): + self.sub_entries.append(child) elif isinstance(self.data, (Parameter, Setpoint, Readback)): self.sub_entries = [self.data] @@ -1204,6 +1217,7 @@ class NestableTableModel(BaseTableEntryModel): # Shows simplified details (created time, description, # pvs, # child colls) # Open details delegate headers: List[str] + _button_cols: List[NestableHeader] = [NestableHeader.OPEN, NestableHeader.REMOVE] _header_to_field: Dict[NestableHeader, str] = { NestableHeader.NAME: 'title', NestableHeader.DESCRIPTION: 'description', @@ -1271,13 +1285,18 @@ def gather_sub_entries(self): # TODO: gather and fill entries where necessary if isinstance(self.data, Nestable): # gather sub_nestables - self.sub_entries = [child for child in self.data.children - if isinstance(child, Nestable)] - - for i, sub_nest in enumerate(self.sub_entries): - if isinstance(sub_nest, UUID): - new_entry = self._client.backend.get_entry(sub_nest) - self.sub_entries[i] = new_entry + for child in self.data.children: + if isinstance(child, UUID): + filled_child = self._client.backend.get_entry(child) + else: + filled_child = child + + if filled_child is None: + raise EntryNotFoundError(f"{child} not found in backend, " + "cannot fill with real data") + + if isinstance(child, Nestable) and isinstance(child, Entry): + self.sub_entries.append(child) class ButtonDelegate(QtWidgets.QStyledItemDelegate): From 863cf451174afa34e9e8269d0fedfecf4e2f23b0 Mon Sep 17 00:00:00 2001 From: tangkong Date: Wed, 9 Oct 2024 16:37:56 -0700 Subject: [PATCH 13/22] MNT: make ResultsModel properly implement BaseTableEntryModel --- superscore/widgets/page/search.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/superscore/widgets/page/search.py b/superscore/widgets/page/search.py index f9a4795..adfc7d0 100644 --- a/superscore/widgets/page/search.py +++ b/superscore/widgets/page/search.py @@ -1,6 +1,7 @@ """Search page""" import logging +from enum import auto from typing import Any, Callable, Dict, List, Optional import qtawesome as qta @@ -12,7 +13,8 @@ from superscore.model import Collection, Entry, Readback, Setpoint, Snapshot from superscore.widgets import ICON_MAP from superscore.widgets.core import Display -from superscore.widgets.views import BaseTableEntryModel, ButtonDelegate +from superscore.widgets.views import (BaseTableEntryModel, ButtonDelegate, + HeaderEnum) logger = logging.getLogger(__name__) @@ -89,8 +91,8 @@ def setup_ui(self) -> None: horiz_header.setSectionResizeMode(horiz_header.Interactive) self.open_delegate = ButtonDelegate(button_text='open me') - del_col = len(ResultModel.headers) - 1 - self.results_table_view.setItemDelegateForColumn(del_col, self.open_delegate) + self.results_table_view.setItemDelegateForColumn(ResultsHeader.OPEN, + self.open_delegate) self.open_delegate.clicked.connect(self.proxy_model.open_row) self.name_subfilter_line_edit.textChanged.connect(self.subfilter_results) @@ -156,12 +158,25 @@ def subfilter_results(self) -> None: self.proxy_model.invalidateFilter() +class ResultsHeader(HeaderEnum): + NAME = 0 + TYPE = auto() + DESCRIPTION = auto() + CREATED = auto() + OPEN = auto() + + class ResultModel(BaseTableEntryModel): - headers: List[str] = ['Name', 'Type', 'Description', 'Created', 'Open'] + headers: List[str] + _button_cols: List[ResultsHeader] = [ResultsHeader.OPEN] + _editable_cols: Dict[int, bool] = {4: True} def __init__(self, *args, entries: List[Entry] = None, **kwargs) -> None: super().__init__(*args, **kwargs) + self.header_enum = ResultsHeader + self.headers = [h.header_name() for h in ResultsHeader] self.entries: List[Entry] = entries or [] + self.set_editable(ResultsHeader.OPEN, True) def data(self, index: QtCore.QModelIndex, role: int) -> Any: """ From 330939b907f986045cea39989a96d5bdd8c453f8 Mon Sep 17 00:00:00 2001 From: tangkong Date: Wed, 9 Oct 2024 16:38:17 -0700 Subject: [PATCH 14/22] MNT: pass client to RootTree so it can fill entries --- superscore/widgets/page/collection_builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superscore/widgets/page/collection_builder.py b/superscore/widgets/page/collection_builder.py index 3bb7cca..adfe340 100644 --- a/superscore/widgets/page/collection_builder.py +++ b/superscore/widgets/page/collection_builder.py @@ -86,7 +86,7 @@ def setup_ui(self): self.sub_coll_table_view.client = self.client self.sub_coll_table_view.set_data(self.data) - self.tree_model = RootTree(base_entry=self.data) + self.tree_model = RootTree(base_entry=self.data, client=self.client) self.tree_view.setModel(self.tree_model) self.sub_coll_table_view.data_updated.connect(self.tree_model.refresh_tree) self.sub_pv_table_view.data_updated.connect(self.tree_model.refresh_tree) From ef4bc1f5a53dc43bad280ba1d67de9cd658a4f3c Mon Sep 17 00:00:00 2001 From: tangkong Date: Thu, 10 Oct 2024 10:25:24 -0700 Subject: [PATCH 15/22] BUG: remove commas that made text fields into tuples --- superscore/widgets/page/collection_builder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/superscore/widgets/page/collection_builder.py b/superscore/widgets/page/collection_builder.py index adfe340..6e1f271 100644 --- a/superscore/widgets/page/collection_builder.py +++ b/superscore/widgets/page/collection_builder.py @@ -118,8 +118,8 @@ def update_model_data(self): def save_collection(self): """Save current collection to database via Client""" - self.data.title = self.meta_widget.name_edit.text(), - self.data.description = self.meta_widget.desc_edit.toPlainText(), + self.data.title = self.meta_widget.name_edit.text() + self.data.description = self.meta_widget.desc_edit.toPlainText() # children should have been updated along the way self.client.save(self.data) logger.info(f"Collection saved ({self.data.uuid})") From 9a7ec2e3ff3da6e39b37b4720a582a6afc12de06 Mon Sep 17 00:00:00 2001 From: tangkong Date: Thu, 10 Oct 2024 12:36:06 -0700 Subject: [PATCH 16/22] MNT: let PV_NAME be editable in collection builder --- superscore/widgets/page/collection_builder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/superscore/widgets/page/collection_builder.py b/superscore/widgets/page/collection_builder.py index 6e1f271..6b9b261 100644 --- a/superscore/widgets/page/collection_builder.py +++ b/superscore/widgets/page/collection_builder.py @@ -82,6 +82,7 @@ def setup_ui(self): for i in [LivePVHeader.STORED_VALUE, LivePVHeader.STORED_SEVERITY, LivePVHeader.STORED_STATUS]: self.sub_pv_table_view.setColumnHidden(i, True) + self.sub_pv_table_view.set_editable(LivePVHeader.PV_NAME, True) self.sub_coll_table_view.client = self.client self.sub_coll_table_view.set_data(self.data) From 833f3aa60c09ff261fc1ea4a1cc20ebe31ccaf00 Mon Sep 17 00:00:00 2001 From: tangkong Date: Thu, 10 Oct 2024 14:10:05 -0700 Subject: [PATCH 17/22] BUG: let (0,0) limits mean no limits, be more precise about enum check --- superscore/widgets/views.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index 4b60349..b6cfc6f 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -911,7 +911,7 @@ def is_close(self, entry: PVEntry, data: Any) -> bool: # data still fetching, don't compare return - if hasattr(e_data, "enums") and isinstance(data, int): + if hasattr(e_data, "enums") and e_data.enums and isinstance(data, int): # Unify enum representation r_data = e_data.enums[data] l_data = e_data.enums[e_data.data] @@ -1365,15 +1365,23 @@ def createEditor( widget.setCurrentIndex(data_val.data) elif isinstance(data_val.data, int): widget = QtWidgets.QSpinBox(parent) + if data_val.lower_ctrl_limit == 0 and data_val.upper_ctrl_limit == 0: + widget.setMaximum(2147483647) + widget.setMinimum(-2147483647) + else: + widget.setMaximum(data_val.upper_ctrl_limit) + widget.setMinimum(data_val.lower_ctrl_limit) widget.setValue(data_val.data) - widget.setMaximum(data_val.upper_ctrl_limit) - widget.setMinimum(data_val.lower_ctrl_limit) elif isinstance(data_val.data, float): widget = QtWidgets.QDoubleSpinBox(parent) - widget.setValue(data_val.data) - widget.setMaximum(data_val.upper_ctrl_limit) - widget.setMinimum(data_val.lower_ctrl_limit) + if data_val.lower_ctrl_limit == 0 and data_val.upper_ctrl_limit == 0: + widget.setMaximum(2147483647) + widget.setMinimum(-2147483647) + else: + widget.setMaximum(data_val.upper_ctrl_limit) + widget.setMinimum(data_val.lower_ctrl_limit) widget.setDecimals(data_val.precision) + widget.setValue(data_val.data) else: logger.debug(f"datatype ({dtype}) incompatible with supported edit " f"widgets: ({data_val})") From f8f42908e308fa28d0ccaddb21ae154c52a2ae48 Mon Sep 17 00:00:00 2001 From: tangkong Date: Thu, 10 Oct 2024 14:35:41 -0700 Subject: [PATCH 18/22] BUG/MNT: fix status/severity handling when setting data and displaying stored values --- superscore/widgets/views.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index b6cfc6f..c33c08c 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -541,13 +541,18 @@ def setData(self, index: QtCore.QModelIndex, value: Any, role: int) -> bool: """Set data""" entry = self.entries[index.row()] header_col = self.header_enum(index.column()) - self.layoutAboutToBeChanged.emit() if header_col in self._button_cols: # button columns do not actually set data, no-op return True + header_field = self._header_to_field[header_col] + if not hasattr(entry, header_field): + # only set values on entries with the field + return True + + self.layoutAboutToBeChanged.emit() try: - setattr(entry, self._header_to_field[header_col], value) + setattr(entry, header_field, value) success = True except Exception as exc: logger.error(f"Failed to set data ({value}) ->" @@ -1343,7 +1348,7 @@ def createEditor( data_val = index.model().data(index, role=QtCore.Qt.DisplayRole) if dtype == DisplayType.PV_NAME: widget = QtWidgets.QLineEdit(data_val, parent) - elif dtype == DisplayType.STATUS: + elif dtype == DisplayType.SEVERITY: widget = QtWidgets.QComboBox(parent) widget.addItems([sev.name for sev in Severity]) elif dtype == DisplayType.STATUS: @@ -1400,6 +1405,15 @@ def setModelData( val = editor.text() elif isinstance(editor, QtWidgets.QComboBox): val = editor.currentIndex() + + dtype: DisplayType = model.data( + index, role=CustRoles.DisplayTypeRole + ) + + if dtype == DisplayType.STATUS: + val = Status(val) + elif dtype == DisplayType.SEVERITY: + val = Severity(val) else: return model.setData(index, val, QtCore.Qt.EditRole) From 17dbe2902ec6e89da9078ae23b0a4a10a3dc859a Mon Sep 17 00:00:00 2001 From: tangkong Date: Tue, 15 Oct 2024 15:46:51 -0700 Subject: [PATCH 19/22] MNT: make BaseDataTableView less strict about order of setting data and client --- superscore/widgets/views.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index c33c08c..88ed2b0 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -1096,12 +1096,22 @@ def set_data(self, data: Any): f"Attempted to set an incompatable data type ({type(data)})" ) self.data = data - self.gather_sub_entries() + self.maybe_setup_model() + def maybe_setup_model(self): + """ + Set up the model if data and client are set + """ if self.client is None: - logger.debug("Client not yet set, cannot initialize model") + logger.debug("Client not set, cannot initialize model") return + if self.data is None: + logger.debug("data not set, cannot initialize model") + return + + self.gather_sub_entries() + if self._model is None: self._model = self._model_cls( client=self.client, @@ -1137,6 +1147,7 @@ def _set_client(self, client: Client): raise ValueError("Provided client is not a superscore Client") self._client = client + self.maybe_setup_model() def set_editable(self, column: int, is_editable: bool) -> None: if not self._model: @@ -1359,7 +1370,7 @@ def createEditor( data_val: EpicsData = index.model().data( index, role=CustRoles.EpicsDataRole ) - if isinstance(data_val, str): + if not isinstance(data_val, EpicsData): # not yet initialized, no-op return if isinstance(data_val.data, str): From cc7a400379e7f6ab2fb7b6eff3af8cac2330dfbc Mon Sep 17 00:00:00 2001 From: tangkong Date: Tue, 15 Oct 2024 15:58:01 -0700 Subject: [PATCH 20/22] TST: add and expand tessts for shared views --- superscore/tests/conftest.py | 9 +++ superscore/tests/test_views.py | 144 ++++++++++++++++++++++++++++++++- 2 files changed, 149 insertions(+), 4 deletions(-) diff --git a/superscore/tests/conftest.py b/superscore/tests/conftest.py index d86873d..943ca70 100644 --- a/superscore/tests/conftest.py +++ b/superscore/tests/conftest.py @@ -707,6 +707,15 @@ def setpoint_with_readback() -> Setpoint: return setpoint +@pytest.fixture(scope="function") +def simple_snapshot() -> Collection: + snap = Snapshot(description='various types', title='types collection') + snap.children.append(Setpoint(pv_name="MY:FLOAT")) + snap.children.append(Setpoint(pv_name="MY:INT")) + snap.children.append(Setpoint(pv_name="MY:ENUM")) + return snap + + @pytest.fixture(scope='function') def filestore_backend(tmp_path: Path) -> FilestoreBackend: fp = Path(__file__).parent / 'db' / 'filestore.json' diff --git a/superscore/tests/test_views.py b/superscore/tests/test_views.py index 73055b5..7a69a50 100644 --- a/superscore/tests/test_views.py +++ b/superscore/tests/test_views.py @@ -1,13 +1,17 @@ +import copy +from typing import Any from unittest.mock import MagicMock +import apischema import pytest from pytestqt.qtbot import QtBot -from qtpy import QtCore +from qtpy import QtCore, QtWidgets from superscore.client import Client from superscore.control_layers import EpicsData -from superscore.model import Parameter -from superscore.widgets.views import LivePVTableModel +from superscore.model import Collection, Parameter, Severity, Status +from superscore.widgets.views import (CustRoles, LivePVHeader, + LivePVTableModel, LivePVTableView) @pytest.fixture(scope='function') @@ -15,7 +19,8 @@ def pv_poll_model( mock_client: Client, parameter_with_readback: Parameter, qtbot: QtBot -) -> LivePVTableModel: +): + """Minimal LivePVTableModel, containing only Parameters (no stored data)""" model = LivePVTableModel( client=mock_client, entries=[parameter_with_readback], @@ -32,6 +37,42 @@ def pv_poll_model( qtbot.wait_until(lambda: not model._poll_thread.isRunning()) +@pytest.fixture(scope="function") +def pv_table_view( + mock_client: Client, + simple_snapshot: Collection, + qtbot: QtBot, +): + """ + LivePVTableView, holds three PVs with different types. Stored data allowed. + Mocks control layer to return realistic-ish EpicsData + + TODO: add string field examples once we properly handle strings + """ + # Build side effect function: + ret_vals = { + "MY:FLOAT": EpicsData(data=0.5, precision=3, + upper_ctrl_limit=2, lower_ctrl_limit=-2), + "MY:INT": EpicsData(data=1, upper_ctrl_limit=10, lower_ctrl_limit=-10), + "MY:ENUM": EpicsData(data=0, enums=["OUT", "IN", "UNKNOWN"]) + } + + def simple_coll_return_vals(pv_name: str): + return ret_vals[pv_name] + + mock_client.cl.get = MagicMock(side_effect=simple_coll_return_vals) + + view = LivePVTableView() + view.client = mock_client + view.set_data(simple_snapshot) + + qtbot.wait_until(lambda: view.model()._poll_thread.isRunning()) + yield view + + view.model().stop_polling() + qtbot.wait_until(lambda: not view.model()._poll_thread.isRunning()) + + def test_pvmodel_polling(pv_poll_model: LivePVTableModel, qtbot: QtBot): thread = pv_poll_model._poll_thread pv_poll_model.stop_polling() @@ -51,3 +92,98 @@ def test_pvmodel_update(pv_poll_model: LivePVTableModel, qtbot: QtBot): qtbot.wait_until( lambda: pv_poll_model.data(data_index, QtCore.Qt.DisplayRole) == '3' ) + + +@pytest.mark.parametrize("row,widget_cls,", [ + (0, QtWidgets.QDoubleSpinBox), + (1, QtWidgets.QSpinBox), + (2, QtWidgets.QComboBox), +]) +def test_pv_view_value_delegate_types( + row: int, + widget_cls: QtWidgets.QWidget, + pv_table_view: LivePVTableView, + qtbot: QtBot, +): + model = pv_table_view.model() + assert isinstance(model, LivePVTableModel) + + index = model.index(row, LivePVHeader.STORED_VALUE) + + # let the data populate before checking delegate type + qtbot.wait_until( + lambda: isinstance(model.data(index, CustRoles.EpicsDataRole), EpicsData) + ) + + edit_widget = pv_table_view.value_delegate.createEditor(pv_table_view, 0, index) + assert isinstance(edit_widget, widget_cls) + + +@pytest.mark.parametrize("col,widget_cls,", [ + (LivePVHeader.PV_NAME, QtWidgets.QLineEdit), + (LivePVHeader.STORED_SEVERITY, QtWidgets.QComboBox), + (LivePVHeader.STORED_STATUS, QtWidgets.QComboBox), +]) +def test_pv_view_common_delegate_types( + col: int, + widget_cls: QtWidgets.QWidget, + pv_table_view: LivePVTableView, + qtbot: QtBot, +): + model = pv_table_view.model() + assert isinstance(model, LivePVTableModel) + + index = model.index(0, col) + + # unlike previous test, no waiting needed, since we don't initialize the + # widget with any live data + + edit_widget = pv_table_view.value_delegate.createEditor(pv_table_view, 0, index) + assert isinstance(edit_widget, widget_cls) + + +@pytest.mark.parametrize("row,input_data,", [ + (0, 0.1), + (1, 2), + (2, 1), # enum types get set as ints, viewed as strings +]) +def test_set_data( + row: int, + input_data: Any, + pv_table_view: LivePVTableView +): + orig_data = copy.deepcopy(pv_table_view.data) + orig_ser = apischema.serialize(type(pv_table_view.data), pv_table_view.data) + + model = pv_table_view.model() + index = model.index(row, LivePVHeader.STORED_VALUE) + + model.setData(index, input_data, QtCore.Qt.EditRole) + + # round-trip ensures types translate properly + new_ser = apischema.serialize(type(pv_table_view.data), pv_table_view.data) + assert orig_data != pv_table_view.data + assert orig_ser != new_ser + + +def test_stat_sev_enums(pv_table_view: LivePVTableView): + model = pv_table_view.model() + sev_index = model.index(0, LivePVHeader.STORED_SEVERITY) + sev_delegate = pv_table_view.value_delegate.createEditor( + pv_table_view, 0, sev_index + ) + + assert isinstance(sev_delegate, QtWidgets.QComboBox) + assert sev_delegate.count() == len(Severity) + for sev in Severity: + assert sev_delegate.itemText(sev.value).lower() == sev.name.lower() + + stat_index = model.index(0, LivePVHeader.STORED_STATUS) + stat_delegate = pv_table_view.value_delegate.createEditor( + pv_table_view, 0, stat_index + ) + + assert isinstance(sev_delegate, QtWidgets.QComboBox) + assert stat_delegate.count() == len(Status) + for stat in Status: + assert stat_delegate.itemText(stat.value).lower() == stat.name.lower() From 8fbac151a246756c5e0b195fd0ab6680875caaa7 Mon Sep 17 00:00:00 2001 From: tangkong Date: Tue, 15 Oct 2024 16:08:41 -0700 Subject: [PATCH 21/22] TST: actually round-trip serialize/deserialize, remove unused qtbot --- superscore/tests/test_views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/superscore/tests/test_views.py b/superscore/tests/test_views.py index 7a69a50..2dcf8ec 100644 --- a/superscore/tests/test_views.py +++ b/superscore/tests/test_views.py @@ -128,7 +128,6 @@ def test_pv_view_common_delegate_types( col: int, widget_cls: QtWidgets.QWidget, pv_table_view: LivePVTableView, - qtbot: QtBot, ): model = pv_table_view.model() assert isinstance(model, LivePVTableModel) @@ -162,6 +161,10 @@ def test_set_data( # round-trip ensures types translate properly new_ser = apischema.serialize(type(pv_table_view.data), pv_table_view.data) + new_data = apischema.deserialize(type(pv_table_view.data), new_ser) + assert pv_table_view.data == new_data + + # data should not be the same as at beginning of test assert orig_data != pv_table_view.data assert orig_ser != new_ser From 8df75f5558b62926ee2108cf22df1ee78a34d4c5 Mon Sep 17 00:00:00 2001 From: Robert Tang-Kong <35379409+tangkong@users.noreply.github.com> Date: Tue, 15 Oct 2024 17:39:13 -0700 Subject: [PATCH 22/22] TST: sort limits from low to high Co-authored-by: Zachary Lentz --- superscore/tests/test_views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/superscore/tests/test_views.py b/superscore/tests/test_views.py index 2dcf8ec..6ba4b21 100644 --- a/superscore/tests/test_views.py +++ b/superscore/tests/test_views.py @@ -52,8 +52,8 @@ def pv_table_view( # Build side effect function: ret_vals = { "MY:FLOAT": EpicsData(data=0.5, precision=3, - upper_ctrl_limit=2, lower_ctrl_limit=-2), - "MY:INT": EpicsData(data=1, upper_ctrl_limit=10, lower_ctrl_limit=-10), + lower_ctrl_limit=-2, upper_ctrl_limit=2), + "MY:INT": EpicsData(data=1, lower_ctrl_limit=-10, upper_ctrl_limit=10), "MY:ENUM": EpicsData(data=0, enums=["OUT", "IN", "UNKNOWN"]) }