Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: Add device descriptions #124

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions conda-recipe/meta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ requirements:
host:
- python >=3.9
- pip
- setuptools_scm
run:
- python >=3.9
- fuzzywuzzy
Expand All @@ -28,6 +29,7 @@ requirements:
- pyqtads
- pyyaml
- qtpy
- ruamel.yaml
- typhos
- happi

Expand Down
116 changes: 103 additions & 13 deletions lucid/overview.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@
import os
import weakref
from functools import partial
from pathlib import Path
from typing import Any, Optional

import yaml
from pydm.widgets import PyDMRelatedDisplayButton, PyDMShellCommand
from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtCore import Property, QEvent, QSize, Qt
from qtpy.QtGui import QHoverEvent
from qtpy.QtWidgets import QGridLayout, QMenu, QPushButton, QWidget
from qtpy.QtWidgets import (QGridLayout, QHBoxLayout, QLineEdit, QMenu,
QPushButton, QWidget, QWidgetAction)
from ruamel.yaml import YAML
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we consolidate all yaml parsing to use ruamel in a future PR? It's kind of strange to use two different libraries for the same thing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ruamel is only really necessary for writing the file, if we want to preserve comments / spacing etc. For reading pyyaml is sufficient. It's probably good to be consistent but I thought it wasn't too important

from typhos.utils import reload_widget_stylesheet

import lucid
Expand All @@ -21,6 +25,52 @@
logger = logging.getLogger(__name__)


class ButtonDescriptionWidget(QWidget):
"""Widget with a button an description line edit"""
def __init__(
self,
*args,
button_text: str = '',
button_cb: Optional[callable] = None,
desc_text: str = '',
desc_cb: Optional[callable] = None,
**kwargs
) -> None:
super().__init__(*args, **kwargs)
self.button = QPushButton(button_text)
if button_cb:
self.button.clicked.connect(button_cb)
self.desc_edit = QLineEdit(desc_text)
self.desc_edit.setPlaceholderText('no description')
if desc_cb:
self.desc_edit.editingFinished.connect(
lambda: desc_cb(self.desc_edit.text())
)
self.hlayout = QHBoxLayout()
self.hlayout.addWidget(self.button)
self.hlayout.addWidget(self.desc_edit)
self.setLayout(self.hlayout)


def get_device_description(device_name: str, config_file: Optional[Any]) -> str:
""" Get the device description from the yaml file"""
if not config_file:
return ""

if isinstance(config_file, (str, bytes, os.PathLike)):
fpath = config_file
with open(config_file, 'r') as tf:
config = yaml.full_load(tf)
else: # some TextIOWrapper
fpath = os.path.abspath(config_file.name)
with open(fpath, 'r') as tf:
config = yaml.full_load(tf)

if not config.get('DEVICE_HINTS'):
return ""
return config['DEVICE_HINTS'].get(device_name, "")


class BaseDeviceButton(QPushButton):
"""Base class for QPushButton to show devices"""
_OPEN_ALL = "Open All"
Expand All @@ -31,6 +81,7 @@ def __init__(self, title, *args, **kwargs):
# References for created screens
self._device_displays = {}
self._suite = None
self.config_file = None
# Setup Menu
self.setContextMenuPolicy(Qt.PreventContextMenu)
self.device_menu = QMenu()
Expand All @@ -44,9 +95,9 @@ def show_device(self, device):
return self._device_displays[device.name]

def show_all(self):
"""Create a widget for contained devices"""
if len(self.devices) == 0:
return None
"""Create a widget for contained devices"""
if not self._suite:
self._suite = suite_for_devices(self.devices, parent=self, pin=True)
else:
Expand All @@ -63,8 +114,14 @@ def _devices_shown(self, shown):

def _menu_shown(self):
# Current menu options
menu_devices = [action.text()
for action in self.device_menu.actions()]
# gather current actions
menu_devices = []
for action in self.device_menu.actions():
if isinstance(action, QWidgetAction):
menu_devices.append(action.defaultWidget().button.text())
else:
menu_devices.append(action.text())

if self._OPEN_ALL not in menu_devices:
show_all_devices = self._show_all_wrapper()
self.device_menu.addAction(self._OPEN_ALL, show_all_devices)
Expand All @@ -74,7 +131,14 @@ def _menu_shown(self):
if device.name not in menu_devices:
# Add to device menu
show_device = self._show_device_wrapper(device)
self.device_menu.addAction(device.name, show_device)
action_widget = ButtonDescriptionWidget(
button_text=device.name, button_cb=show_device,
desc_text=get_device_description(device.name, self.config_file),
desc_cb=partial(self._update_desc, device.name)
)
action = QWidgetAction(self.device_menu)
action.setDefaultWidget(action_widget)
self.device_menu.addAction(action)

def _show_all_wrapper(self):
return lucid.LucidMainWindow.in_dock(
Expand All @@ -88,6 +152,31 @@ def _show_device_wrapper(self, device):
partial(self.show_device, device),
title=device.name)

def _update_desc(self, device_name: str, device_desc: str) -> None:
if self.config_file:
logger.debug(f'updating device description: {device_name} = {device_desc}')
# open yaml file
ryaml = YAML()
ryaml.default_flow_style = False
if isinstance(self.config_file, (str, bytes, os.PathLike)):
fpath = self.config_file
with open(self.config_file, 'r') as tf:
config = ryaml.load(tf)
else:
fpath = os.path.abspath(self.config_file.name)
with open(fpath, 'r') as tf:
config = ryaml.load(tf)

if not config.get('DEVICE_HINTS'):
config['DEVICE_HINTS'] = {}
if device_desc == config['DEVICE_HINTS'].get(device_name):
return
if device_desc == '':
config['DEVICE_HINTS'].pop(device_name, None)
else:
config['DEVICE_HINTS'][device_name] = device_desc
ryaml.dump(config, Path(fpath))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the first time in lucid where the user config file is written back to by the program.
Could this create issues where someone is editing the user config file and someone else is setting descriptions? Maybe.
I think we should consider using a second file for this sort of extra data.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Putting it all in the same file is probably the easy way out. I suppose the case where multiple lucid's are open at the same time will hit upon this rather quickly.

This reminds me of the issue we had in happi where a crash interrupts writing and wipes the file. I should do the write-new-file -> copy over sequence we implemented there.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me, this feels qualitatively different to happi which has a cli tool for editing the file, whereas in this project the user is expected to edit the file themselves.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I commented on the typhos PR before making it back to this one... Separating the files makes sense.
Backing up a step, though...

You mention happi, @ZLLentz, did you guys discuss about putting these notes directly into the database? It seems sensible to have shared notes in a database to me. Or is it like different hutches should have different notes about a shared beamline component or something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that happi could become a source for notes, but at a lower priority (shadowed by local or env var notes). This is precisely for the reason you mentioned; different hutches might be looking at the same beamline component and have different notes, and I wouldn't want them to clobber each other.

The other wrinkle is that happi makes sense for top-level device information, but storing component level notes in happi might get a bit messy (though not completely unreasonable).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like we've talked about storing extra data in happi as a go-to per-device data source a bunch of times in many contexts, which makes me think that it's worth doing. It might even be worth storing subdevice notes along with the device notes, I'm not sure? It's not immediately clear to me and warrants some discussion and mock-ups.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a lot of sources of notes and documentation... Off the top of my head, correct me if I'm wrong:

  1. LUCID config file 1 - env var notes
  2. LUCID config file 2 - local notes
  3. typhos config file 1 - env var notes
  4. typhos config file 2 - local notes
  5. happi database
  6. confluence - PCDS
  7. confluence - mechanical team / hutch team / etc
  8. FRS / ESD / ...
  9. Design review slides...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To some extent the Typhos PR could supercede this one, or at least LUCID/Typhos could use the same files. (so maybe that simplifies things slightly)

Adding yet another source of documentation that can fall out of date leaves a slightly bitter after taste, but I could argue it fills a slightly different niche


def eventFilter(self, obj, event):
"""
QWidget.eventFilter to be installed on child indicators
Expand All @@ -101,10 +190,7 @@ def eventFilter(self, obj, event):
self._show_all_wrapper()()
return True
elif event.button() == Qt.LeftButton:
if len(self.devices) == 1:
self._show_device_wrapper(self.devices[0])()
else:
self.device_menu.exec_(self.mapToGlobal(event.pos()))
self.device_menu.exec_(self.mapToGlobal(event.pos()))
return True
return False

Expand All @@ -116,7 +202,7 @@ class IndicatorCell(BaseDeviceButton):
spacing = 1
margin = 5

def __init__(self, *args, **kwargs):
def __init__(self, *args, toolbar=None, **kwargs):
super().__init__(*args, **kwargs)
# Disable borders on the widget unless a hover occurs
self.setStyleSheet('QPushButton:!hover {border: None}')
Expand All @@ -126,6 +212,7 @@ def __init__(self, *args, **kwargs):
self._selecting_widgets = list()
self.installEventFilter(self)
self.devices = list()
self.config_file = toolbar

@property
def matchable_names(self):
Expand Down Expand Up @@ -171,12 +258,13 @@ def _devices_shown(self, shown, selector=None):
class IndicatorGroup(BaseDeviceButton):
"""QPushButton to select an entire row or column of devices"""

def __init__(self, *args, orientation, **kwargs):
def __init__(self, *args, orientation, toolbar=None, **kwargs):
super().__init__(*args, **kwargs)
self.setText(str(self.title))
self.cells = []
self.installEventFilter(self)
self.orientation = orientation
self.config_file = toolbar

def add_cell(self, cell):
self.cells.append(cell)
Expand Down Expand Up @@ -229,7 +317,7 @@ def groups(self):

def add_devices(self, devices, system=None, stand=None):
# Create cell
cell = IndicatorCell(title=f'{stand} {system}')
cell = IndicatorCell(title=f'{stand} {system}', toolbar=self.toolbar_file or None)
for device in devices:
cell.add_device(device)
# Add to proper location in grid
Expand All @@ -252,7 +340,8 @@ def add_devices(self, devices, system=None, stand=None):
def _add_group(self, group, as_row):
# Add to layout
group = IndicatorGroup(title=group,
orientation='row' if as_row else 'column')
orientation='row' if as_row else 'column',
toolbar=self.toolbar_file or None)
self._groups[group.title] = group
# Find the correct position
if as_row:
Expand Down Expand Up @@ -359,6 +448,7 @@ def __init__(self, parent=None, toolbar_file=None):
self.frame = QtWidgets.QFrame(parent)
self.frame.setLayout(QtWidgets.QVBoxLayout())
self.frame.layout().addWidget(self)
self.toolbar_file = toolbar_file

if toolbar_file is not None:
vertical_spacer = QtWidgets.QSpacerItem(
Expand Down
11 changes: 8 additions & 3 deletions lucid/tests/test_overview.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import pytest
from ophyd.sim import SynAxis, motor
from qtpy.QtWidgets import QWidget
from qtpy.QtWidgets import QWidget, QWidgetAction

from lucid.overview import IndicatorCell

Expand All @@ -18,9 +18,14 @@ def test_base_device_button_menu(cell):
motor = SynAxis(name=f'motor_{i}')
cell.add_device(motor)
cell._menu_shown()
device_names = []
for action in cell.device_menu.actions():
if isinstance(action, QWidgetAction):
device_names.append(action.defaultWidget().button.text())
else:
device_names.append(action.text())
for device in cell.devices:
assert device.name in [action.text()
for action in cell.device_menu.actions()]
assert device.name in device_names


def test_base_device_button_show_device(cell):
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ pyqt5>=5
pyqtads
pyyaml
qtpy
ruamel.yaml
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unrelated: why are package names allowed to contain periods?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like it either, but it's a pretty widely used package so it seems

typhos