Merge branch 'master' of gitlab.psi.ch:augustin_s/grum into tests

This commit is contained in:
stalbe_j
2023-02-06 13:34:23 +01:00
17 changed files with 286 additions and 47 deletions

View File

@ -25,13 +25,14 @@ def handle_clargs():
DESC = "grum - GUI for Remote Unified Monitoring"
parser = argparse.ArgumentParser(description=DESC, formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("-H", "--host", default="localhost", help="Server host name")
parser.add_argument("-P", "--port", default=8000, type=int, help="Server port number")
parser.add_argument("-H", "--host", default="localhost", help="RPC server host name")
parser.add_argument("-P", "--port", default=8000, type=int, help="RPC server port number")
parser.add_argument("-o", "--offline", action="store_true", help="offline mode (do not run RPC server)")
parser.add_argument("-w", "--window-mode", default="multi", choices=MDIWindowMode.values(), type=unambiguous_window_mode, help="Set the initial window mode")
parser.add_argument("-w", "--window-mode", default="multi", choices=MDIWindowMode.values(), type=unambiguous_window_mode, help="set the initial window mode")
parser.add_argument("-e", "--examples", dest="add_examples", action="store_true", help="Add example data")
parser.add_argument("--no-theme", action="store_true", help="Disable theming")
parser.add_argument("-e", "--examples", dest="add_examples", action="store_true", help="add example data")
parser.add_argument("--no-theme", action="store_true", help="disable theming")
return parser.parse_args().__dict__

View File

@ -1,4 +1,7 @@
from PyQt5.QtWidgets import QWidget, QLineEdit, QVBoxLayout
import fnmatch
import re
from PyQt5.QtWidgets import QWidget, QVBoxLayout
from .dictlistwidget import DictListWidget
from .searchbox import SearchBox
@ -31,11 +34,26 @@ class DictList(QWidget):
def hide_not_matching(self, pattern):
pattern = pattern.casefold()
g = Globber(pattern)
for name, itm in self.lst.items.items():
name = name.casefold()
state = (pattern not in name)
itm.setHidden(state)
state = g.match(name)
itm.setHidden(not state)
class Globber:
def __init__(self, pattern):
pattern = pattern.casefold()
pattern = "*" + pattern + "*"
regex = fnmatch.translate(pattern)
self.pattern = re.compile(regex)
def match(self, string):
string = string.casefold()
matches = self.pattern.match(string)
state = bool(matches)
return state

View File

@ -3,6 +3,7 @@ from PyQt5.QtWidgets import QListWidgetItem
from PyQt5.QtGui import QIcon, QPixmap, QPainter
from ..theme import DOT_PEN, DOT_BRUSH, DOT_SIZE
from .timestamps import Timestamps
class DictListItem(QListWidgetItem):
@ -11,9 +12,25 @@ class DictListItem(QListWidgetItem):
super().__init__(key, *args, **kwargs)
self.key = key
self.value = value
self.timestamps = Timestamps()
self.set_alarm(False)
_sort_key = None
@classmethod # need to update the class so that all instances are always consistent
def set_sort_key(cls, sk):
cls._sort_key = staticmethod(sk) # need to attach as staticmethod, otherwise self is inserted as first argument
def __lt__(self, other):
assert self._sort_key == other._sort_key
sk = self._sort_key
if sk:
return sk(self) < sk(other)
else:
return super().__lt__(other)
def set_alarm(self, state):
self.set_bold(state)
self.set_dot(state)

View File

@ -14,8 +14,12 @@ class DictListWidget(QListWidget):
self.setSelectionMode(QListWidget.ExtendedSelection)
self.items = {}
self._add_menu()
shortcut(self, "Del", self.delete_selected, context=Qt.WidgetShortcut)
self.nkeep = None
self.model().rowsInserted.connect(self.on_evict)
def _add_menu(self):
self.menu = menu = RClickMenu(self)
@ -72,4 +76,69 @@ class DictListWidget(QListWidget):
i.set_alarm(state)
def set_sort_key(self, sk, reverse=True):
DictListItem.set_sort_key(sk)
if sk is None:
self.setSortingEnabled(False)
else:
self.setSortingEnabled(True)
order = Qt.DescendingOrder if reverse else Qt.AscendingOrder
self.sortItems(order)
def enable_sort_by_insertion_order(self):
# map order in dict to indices
mapping = enumerate(self.items.values())
mapping = {item.text(): index for index, item in mapping}
def unsort(x):
return mapping[x.text()]
self.set_sort_key(unsort)
self.disable_sorting()
def enable_sort_by_text(self):
self.set_sort_key(lambda x: x.text(), reverse=False)
def enable_sort_by_timestamp(self):
# fall back to name for identical timestamps
self.set_sort_key(lambda x: (x.timestamps.max(), x.text()))
def disable_sorting(self):
self.set_sort_key(None)
def set_nkeep(self, n):
self.nkeep = n
self.on_evict()
def on_evict(self):
if self.nkeep:
self.evict(self.nkeep)
def evict(self, nkeep):
items = self.items.values()
# map order in dict to indices
mapping = enumerate(items)
mapping = {item.text(): index for index, item in mapping}
def sk(x):
# fall back to insertion order for identical timestamps
return (x.timestamps.max(), mapping[x.text()])
items = sorted(items, key=sk, reverse=True)
items_to_evict = items[nkeep:]
for i in items_to_evict:
self.items.pop(i.key)
self.deleteItem(i)

View File

@ -47,7 +47,7 @@ class SearchBox(QWidget):
class SquareButton(QPushButton):
def resizeEvent(self, e):
def resizeEvent(self, _event):
height = self.height()
self.setMinimumWidth(height)

View File

@ -20,15 +20,15 @@ for i in range(20):
exampledata = {}
for k, v in exampledata_raw.items():
pd = PlotDescription(
k,
# title=k,
for name, (xs, ys) in exampledata_raw.items():
exampledata[name] = PlotDescription(
name,
# title=name,
xlabel="x",
ylabel="y"
ylabel="y",
xs=xs,
ys=ys
)
pd.xs, pd.ys = v
exampledata[k] = pd

View File

@ -31,7 +31,7 @@ def read_dict(fn):
data = node[()]
if isinstance(data, bytes):
data = data.decode("utf-8")
res[node.name] = data
res[name] = data
with h5py.File(fn, "r") as f:
f.visititems(visit)
@ -44,7 +44,7 @@ def unflatten_dict(d, sep="/"):
for k, v in d.items():
current = res
levels = k.split(sep)
for l in levels[1:-1]:
for l in levels[:-1]:
if l not in current:
current[l] = {}
current = current[l]

View File

@ -18,8 +18,12 @@ class MainWindow(QMainWindow):
sig_make_new_plot = pyqtSignal(str, PlotDescription)
def __init__(self, *args, title="grum", host="localhost", port=8000, add_examples=False, window_mode=MDIWindowMode.MULTI, **kwargs):
def __init__(self, *args, title="grum", host="localhost", port=8000, offline=False, add_examples=False, window_mode=MDIWindowMode.MULTI, **kwargs):
super().__init__(*args, **kwargs)
if offline:
title = f"{title} (offline)"
self.setWindowTitle(title)
self.setWindowIcon(assets.icon())
@ -39,6 +43,19 @@ class MainWindow(QMainWindow):
lst_menu.addSeparator()
lst_menu.addAction("Mark selected as seen", self.on_mark_selected_as_seen)
lst_menu.addAction("Mark selected as not seen", self.on_mark_selected_as_not_seen)
lst_menu.addSeparator()
sort_group = lst_menu.addGroup()
sort_group.addCheckbox("Sort by insertion order", triggered=self.on_sort_by_insertion_order, state=True)
sort_group.addCheckbox("Sort by name", triggered=self.on_sort_by_name)
sort_group.addCheckbox("Sort by timestamp", triggered=self.on_sort_by_timestamp)
sort_group.addCheckbox("Sorting disabled", triggered=self.on_sorting_disabled)
#TODO: clean up
def on_item_about_to_be_moved():
sort_group.checkboxes["Sorting disabled"].setChecked(True)
self.on_sorting_disabled()
self.lst.model().rowsAboutToBeMoved.connect(on_item_about_to_be_moved)
shortcut(self, "Ctrl+P", self.on_plot_selected)
@ -52,6 +69,8 @@ class MainWindow(QMainWindow):
self.menu_settings = menu = BarMenu(bar, "&Settings")
menu.addCheckbox("Open new plots", state=True)
menu.addSeparator()
menu.addEntrybox("Limit number of entries", placeholder="Maximum number of entries", triggered=lst.set_nkeep)
self.mdi = mdi = MDIArea(bar, window_mode=window_mode)
@ -62,10 +81,11 @@ class MainWindow(QMainWindow):
self.setCentralWidget(splitter)
self.rst = rst = RPCServerThread(host, port, doc_title_suffix=title)
rst.start()
rst.server.register_function(self.new_plot)
rst.server.register_function(self.append_data)
if not offline:
self.rst = rst = RPCServerThread(host, port, doc_title_suffix=title)
rst.start()
rst.server.register_function(self.new_plot)
rst.server.register_function(self.append_data)
self.sig_make_new_plot.connect(self.on_make_new_plot)
@ -78,12 +98,21 @@ class MainWindow(QMainWindow):
# Remote API calls
def new_plot(self, name, cfg):
"""
Create a new plot <name> using the configuration dict <cfg>.
The configuration is forwarded to the constructor of PlotDescription.
Allowed keys are: title, xlabel, ylabel, xs, ys.
"""
desc = self.add_new_desc_to_list(name, cfg)
if self.menu_settings.checkboxes["Open new plots"].isChecked():
if not self.mdi.findSubWindow(name):
self.sig_make_new_plot.emit(name, desc)
def append_data(self, name, point):
"""
Append a new data point <point> to the (existing) plot <name>.
The point is forwarded to the append method of PlotDescription.
"""
item = self.lst.get(name)
desc = item.value
desc.append(point)
@ -93,6 +122,7 @@ class MainWindow(QMainWindow):
plot = sub.plots[name]
plot.setData(*desc.data)
alarm = False
item.timestamps.modification.update()
item.set_alarm(alarm)
@ -114,6 +144,7 @@ class MainWindow(QMainWindow):
else:
self.plot_multiple_items(selected)
def on_mark_selected_as_seen(self):
self.lst.set_alarm_for_selected(False)
@ -121,6 +152,19 @@ class MainWindow(QMainWindow):
self.lst.set_alarm_for_selected(True)
def on_sort_by_insertion_order(self):
self.lst.enable_sort_by_insertion_order()
def on_sort_by_name(self):
self.lst.enable_sort_by_text()
def on_sort_by_timestamp(self):
self.lst.enable_sort_by_timestamp()
def on_sorting_disabled(self):
self.lst.disable_sorting()
def on_file_open(self):
fns = open_h5_files_dialog(self)
if not fns:
@ -153,12 +197,14 @@ class MainWindow(QMainWindow):
return desc
def plot_single_item(self, item):
item.timestamps.access.update()
item.set_alarm(False)
name, desc = item.key, item.value
self.activate_or_make_subwin(MDISubPlot, name, desc)
def plot_multiple_items(self, items):
for i in items:
i.timestamps.access.update()
i.set_alarm(False)
descs = {i.key: i.value for i in items}
names = descs.keys()

View File

@ -20,7 +20,7 @@ class MDIWindowMode(str, enum.Enum):
class MDIArea(QMdiArea):
def __init__(self, bar, window_mode=MDIWindowMode.MULTI, *args, **kwargs):
def __init__(self, bar, *args, window_mode=MDIWindowMode.MULTI, **kwargs):
super().__init__(*args, **kwargs)
self.logo = assets.logo()
self.setTabsClosable(True)
@ -67,6 +67,7 @@ class MDIArea(QMdiArea):
self.menu.checkboxes["Multiple windows"].setChecked(True)
self.enable_subwindow_view()
for sub in self.subWindowList():
sub.restore()
sub.frame_on()
def enable_single_window_mode(self):
@ -75,7 +76,7 @@ class MDIArea(QMdiArea):
self.closeInactiveSubWindows()
active = self.activeSubWindow()
if active:
active.showMaximized()
active.maximize()
active.frame_off()
def enable_tabbed_mode(self):
@ -104,7 +105,7 @@ class MDIArea(QMdiArea):
def add_single(self, sub):
self.closeAllSubWindows()
self.addSubWindow(sub)
sub.showMaximized()
sub.maximize()
sub.frame_off()

View File

@ -3,30 +3,48 @@ from .mdisubwin import MDISubWindow
from ..theme import pg_plot_style, pg_plot_style_cycler, pg_legend_style
class MDISubPlot(MDISubWindow):
class MDISubPlotBase(MDISubWindow):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.pw = pw = pg.PlotWidget()
self.setWidget(pw)
# connect to plot mouse-over event
pw.scene().sigMouseMoved.connect(self.on_hover)
def on_hover(self, event):
coord = self.pw.plotItem.vb.mapSceneToView(event)
x = coord.x()
y = coord.y()
x = round(x, 3)
y = round(y, 3)
self.setToolTip(f"x = {x}\ny = {y}")
class MDISubPlot(MDISubPlotBase):
def __init__(self, name, desc, *args, **kwargs):
super().__init__(name, *args, **kwargs)
pw = pg.PlotWidget()
self.setWidget(pw)
style = pg_plot_style()
plot = desc.make_plot(pw, style)
plot = desc.make_plot(self.pw, style)
self.plots = {name: plot}
class MDISubMultiPlot(MDISubWindow):
class MDISubMultiPlot(MDISubPlotBase):
def __init__(self, name, descs, *args, **kwargs):
super().__init__(name, *args, **kwargs)
pw = pg.PlotWidget()
self.setWidget(pw)
ls = pg_legend_style()
psc = pg_plot_style_cycler()
pw = self.pw
pw.addLegend(**ls)
self.plots = {name: desc.make_plot(pw, style) for (name, desc), style in zip(descs.items(), psc)}

View File

@ -14,12 +14,27 @@ class MDISubWindow(QMdiSubWindow):
# without this, the SubWindow is not removed from the subWindowList
self.setAttribute(Qt.WA_DeleteOnClose)
self._previous_state = None
def maximize(self):
self._previous_state = state = self.windowState()
self.setWindowState(state | Qt.WindowMaximized)
def restore(self):
if self._previous_state:
self.setWindowState(self._previous_state)
def frame_on(self):
self.setWindowFlag(Qt.FramelessWindowHint, False)
self.hide()
self.setWindowFlags(Qt.SubWindow)
self.show()
def frame_off(self):
self.setWindowFlag(Qt.FramelessWindowHint, True)
self.hide()
self.setWindowFlags(Qt.CustomizeWindowHint | Qt.WindowTitleHint)
self.show()

View File

@ -1,5 +1,5 @@
from PyQt5.QtGui import QKeySequence
from PyQt5.QtWidgets import QAction, QActionGroup
from PyQt5.QtGui import QKeySequence, QIntValidator
from PyQt5.QtWidgets import QAction, QActionGroup, QWidgetAction, QLineEdit
class MenuBase:
@ -31,6 +31,48 @@ class MenuBase:
self.checkboxes[name] = action
return action
def addEntrybox(self, name, placeholder=None, state=False, triggered=None):
cb = self.addCheckbox(name, state=state)
edit = QLineEdit(self.qmenu)
edit.setValidator(QIntValidator()) #TODO: make optional
edit.setEnabled(state)
if placeholder:
edit.setPlaceholderText(placeholder)
edit.setContentsMargins(8, 5, 8, 5) # mimic margins of other actions TODO: this probably depends on the theme
@cb.toggled.connect
def propagate_state_and_keep_menu_open_and_focus_edit(checked):
edit.setEnabled(checked)
if checked:
self.qmenu.show()
self.qmenu.setActiveAction(cb)
edit.setFocus()
@edit.returnPressed.connect
def close_menu():
self.qmenu.close()
if triggered:
@cb.triggered.connect
def confirm_disabled(checked):
if not checked:
triggered(None)
if triggered:
@edit.returnPressed.connect
def confirm_value():
value = edit.text()
value = int(value)
triggered(value)
action = QWidgetAction(self.qmenu)
action.setDefaultWidget(edit)
self.qmenu.addAction(action)
return action
def addSeparator(self):
self.qmenu.addSeparator()

View File

@ -4,7 +4,7 @@ from inspect import getdoc, signature
class RPCServer(xrs.DocXMLRPCServer):
def __init__(self, host, port, doc_title_suffix="", *args, **kwargs):
def __init__(self, host, port, *args, doc_title_suffix="", **kwargs):
addr = (host, port)
kwargs.setdefault("allow_none", True)
super().__init__(addr, *args, **kwargs)

View File

@ -4,9 +4,9 @@ from PyQt5.QtWidgets import QShortcut
def shortcut(parent, key_sequence, triggered, **kwargs):
key_sequence = QKeySequence(key_sequence)
shortcut = QShortcut(key_sequence, parent, **kwargs)
shortcut.activated.connect(triggered)
return shortcut
sc = QShortcut(key_sequence, parent, **kwargs)
sc.activated.connect(triggered)
return sc

View File

@ -27,7 +27,7 @@ DOT_PEN = GREY5
DOT_BRUSH = HIGHLIGHT
DOT_SIZE = 8
LINE_COLORS = [
LINE_COLORS = (
C.BLUE,
C.BASE_GREEN,
C.RED,
@ -38,7 +38,7 @@ LINE_COLORS = [
C.COOL3,
C.BROWN1,
C.BASE_ORANGE,
]
)

12
tests/run.sh Executable file
View File

@ -0,0 +1,12 @@
#!/bin/bash
cd $(dirname $0)
cd ..
export QT_QPA_PLATFORM=offscreen
coverage run --source=./grum/ -m pytest ./tests/
echo
coverage report

View File

@ -143,14 +143,14 @@ class TestMDIArea:
mdi.closeAllSubWindows = mock.MagicMock()
sine_item = self.mw.lst.lst.get("sine")
sub = MDISubPlot("sine", sine_item.value)
sub.showMaximized = mock.MagicMock()
sub.maximize = mock.MagicMock()
sub.frame_off = mock.MagicMock()
mdi.add_single(sub)
mdi.closeAllSubWindows.assert_called_once()
mdi.addSubWindow.assert_called_once_with(sub)
sub.showMaximized.assert_called_once()
sub.maximize.assert_called_once()
sub.frame_off.assert_called_once()