From 8d3e6cbd22b0fee3212042da95b1bf3ae11581ae Mon Sep 17 00:00:00 2001 From: ntjess Date: Fri, 24 Sep 2021 00:06:00 -0400 Subject: [PATCH] Better parameter tree testing (#1953) * Allows values to be numpy arrays * Bugfix: Slider now works when limits didn't change during `optsChanged` * Improved testing + layout of param tree example * Also fix numpy-like values in list `setValue` * use proper hex formatting for value * Fix code warnings * Avoids use of configfile in parametertree * Avoid shadowing variable names * Add explanatory comment to `makeAllParamTypes` * Allow string options to be 'unset' in file, etc. parameters example * Bugfix: unintunitive option unsetting for file window title * don't use lambda in signal connect * Remove unused import --- examples/_buildParamTypes.py | 93 +++++++++ examples/_paramtreecfg.py | 187 ++++++++++++++++++ examples/parametertree.py | 52 +---- pyqtgraph/configfile.py | 43 ++-- pyqtgraph/parametertree/Parameter.py | 2 +- .../parametertree/parameterTypes/file.py | 3 +- .../parametertree/parameterTypes/list.py | 28 +-- .../parametertree/parameterTypes/slider.py | 3 +- 8 files changed, 329 insertions(+), 82 deletions(-) create mode 100644 examples/_buildParamTypes.py create mode 100644 examples/_paramtreecfg.py diff --git a/examples/_buildParamTypes.py b/examples/_buildParamTypes.py new file mode 100644 index 00000000..b903920c --- /dev/null +++ b/examples/_buildParamTypes.py @@ -0,0 +1,93 @@ +from pyqtgraph.parametertree import Parameter +from pyqtgraph.parametertree.Parameter import PARAM_TYPES +from pyqtgraph.parametertree.parameterTypes import GroupParameter +from ._paramtreecfg import cfg + +_encounteredTypes = {'group'} + +def makeChild(chType, cfgDict): + _encounteredTypes.add(chType) + param = Parameter.create(name='widget', type=chType) + param.setDefault(param.value()) + + def setOpt(_param, _val): + # Treat blank strings as "None" to allow 'unsetting' that option + if isinstance(_val, str) and _val == '': + _val = None + param.setOpts(**{_param.name(): _val}) + + optsChildren = [] + metaChildren = [] + for optName, optVals in cfgDict.items(): + child = Parameter.create(name=optName, **optVals) + if ' ' in optName: + metaChildren.append(child) + else: + optsChildren.append(child) + child.sigValueChanged.connect(setOpt) + # Poplate initial options + for p in optsChildren: + setOpt(p, p.value()) + + grp = Parameter.create(name=f'Sample {chType.title()}', type='group', children=metaChildren + [param] + optsChildren) + grp.setOpts(expanded=False) + return grp + +def makeMetaChild(name, cfgDict): + children = [] + for chName, chOpts in cfgDict.items(): + if not isinstance(chOpts, dict): + ch = Parameter.create(name=chName, type=chName, value=chOpts) + else: + ch = Parameter.create(name=chName, **chOpts) + _encounteredTypes.add(ch.type()) + children.append(ch) + param = Parameter.create(name=name, type='group', children=children) + param.setOpts(expanded=False) + return param + +def makeAllParamTypes(): + children = [] + for name, paramCfg in cfg.items(): + if ' ' in name: + children.append(makeMetaChild(name, paramCfg)) + else: + children.append(makeChild(name, paramCfg)) + + params = Parameter.create(name='Example Parameters', type='group', children=children) + + # Slider needs minor tweak + sliderGrp = params.child('Sample Slider') + slider = sliderGrp.child('widget') + slider.setOpts(limits=[0, 100]) + + # Also minor tweak to meta opts + def setOpt(_param, _val): + infoChild.setOpts(**{_param.name(): _val}) + meta = params.child('Applies to All Types') + infoChild = meta.child('Extra Information') + for child in meta.children()[1:]: + child.sigValueChanged.connect(setOpt) + + def onChange(_param, _val): + if _val == 'Use span': + span = slider.opts.pop('span', None) + slider.setOpts(span=span) + else: + limits = slider.opts.pop('limits', None) + slider.setOpts(limits=limits) + sliderGrp.child('How to Set').sigValueChanged.connect(onChange) + + def activate(action): + for ch in params: + if isinstance(ch, GroupParameter): + ch.setOpts(expanded=action.name() == 'Expand All') + + for name in 'Collapse', 'Expand': + btn = Parameter.create(name=f'{name} All', type='action') + btn.sigActivated.connect(activate) + params.insertChild(0, btn) + missing = set(PARAM_TYPES).difference(_encounteredTypes) + if missing: + raise RuntimeError(f'{missing} parameters are not represented') + return params diff --git a/examples/_paramtreecfg.py b/examples/_paramtreecfg.py new file mode 100644 index 00000000..241d4679 --- /dev/null +++ b/examples/_paramtreecfg.py @@ -0,0 +1,187 @@ +import numpy as np + +from pyqtgraph.Qt import QtWidgets +from pyqtgraph.parametertree.parameterTypes import QtEnumParameter as enum + +dlg = QtWidgets.QFileDialog + +cfg = { + 'list': { + 'limits': { + 'type': 'checklist', + 'limits': ['a', 'b', 'c'] + } + }, + 'file': { + 'acceptMode': { + 'type': 'list', + 'limits': list(enum(dlg.AcceptMode, dlg).enumMap) + }, + 'fileMode': { + 'type': 'list', + 'limits': list(enum(dlg.FileMode, dlg).enumMap) + }, + 'viewMode': { + 'type': 'list', + 'limits': list(enum(dlg.ViewMode, dlg).enumMap) + }, + 'dialogLabel': { + 'type': 'list', + 'limits': list(enum(dlg.DialogLabel, dlg).enumMap) + }, + 'relativeTo': { + 'type': 'str', + 'value': None + }, + 'directory': { + 'type': 'str', + 'value': None + }, + 'windowTitle': { + 'type': 'str', + 'value': None + }, + 'nameFilter': { + 'type': 'str', + 'value': None + } + }, + 'float': { + 'Float Information': { + 'type': 'str', + 'readonly': True, + 'value': 'Note that all options except "finite" also apply to "int" parameters', + }, + 'step': { + 'type': 'float', + 'limits': [0, None], + 'value': 1, + }, + 'limits': { + 'type': 'list', + 'limits': {'[0, None]': [0, None], '[1, 5]': [1, 5]}, + }, + 'suffix': { + 'type': 'list', + 'limits': ['Hz', 's', 'm'], + }, + 'siPrefix': { + 'type': 'bool', + 'value': True + }, + 'finite': { + 'type': 'bool', + 'value': True, + }, + 'dec': { + 'type': 'bool', + 'value': False, + }, + 'minStep': { + 'type': 'float', + 'value': 1.0e-12, + }, + }, + + 'checklist': { + 'limits': { + 'type': 'checklist', + 'limits': ['one', 'two', 'three', 'four'], + }, + 'exclusive': { + 'type': 'bool', + 'value': False, + } + }, + + 'pen': { + 'Pen Information': { + 'type': 'str', + 'value': 'Click the button to see options', + 'readonly': True, + }, + }, + + 'slider': { + 'step': { + 'type': 'float', + 'limits': [0, None], + 'value': 1, }, + 'format': { + 'type': 'str', + 'value': '{0:>3}', + }, + 'precision': { + 'type': 'int', + 'value': 2, + 'limits': [1, None], + }, + 'span': { + 'type': 'list', + 'limits': {'linspace(-pi, pi)': np.linspace(-np.pi, np.pi), 'arange(10)**2': np.arange(10) ** 2}, + }, + + 'How to Set': { + 'type': 'list', + 'limits': ['Use span', 'Use step + limits'], + } + }, + + 'calendar': { + 'format': { + 'type': 'str', + 'value': 'MM DD', + } + }, + + 'Applies to All Types': { + 'Extra Information': { + 'type': 'text', + 'value': 'These apply to all parameters. Watch how this text box is altered by any setting you change.', + 'default': 'These apply to all parameters. Watch how this text box is altered by any setting you change.', + 'readonly': True, + }, + 'readonly': { + 'type': 'bool', + 'value': True, + }, + 'removable': { + 'type': 'bool', + 'tip': 'Adds a context menu option to remove this parameter', + 'value': False, + }, + 'visible': { + 'type': 'bool', + 'value': True, + }, + 'disabled': { + 'type': 'bool', + 'value': False, + }, + 'title': { + 'type': 'str', + 'value': 'Meta Options', + }, + 'default': { + 'tip': 'The default value that gets set when clicking the arrow in the right column', + 'type': 'str', + }, + 'expanded': { + 'type': 'bool', + 'value': True, + }, + }, + + 'No Extra Options': { + 'text': 'Unlike the other parameters shown, these don\'t have extra settable options.\n' \ + + 'Note: "int" *does* have the same options as float, mentioned above', + 'int': 10, + 'str': 'Hi, world!', + 'color': '#fff', + 'bool': False, + 'colormap': None, + 'progress': 50, + 'action': None, + 'font': 'Inter', + } +} \ No newline at end of file diff --git a/examples/parametertree.py b/examples/parametertree.py index d621edd3..b7fca5cb 100644 --- a/examples/parametertree.py +++ b/examples/parametertree.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ This example demonstrates the use of pyqtgraph's parametertree system. This provides a simple way to generate user interfaces that control sets of parameters. The example @@ -6,17 +5,20 @@ demonstrates a variety of different parameter types (int, float, list, etc.) as well as some customized parameter types """ -import os - import initExample ## Add path to library (just for examples; you do not need this) import pyqtgraph as pg -from pyqtgraph.Qt import QtCore, QtGui +# `makeAllParamTypes` creates several parameters from a dictionary of config specs. +# This contains information about the options for each parameter so they can be directly +# inserted into the example parameter tree. To create your own parameters, simply follow +# the guidelines demonstrated by other parameters created here. +from examples._buildParamTypes import makeAllParamTypes +from pyqtgraph.Qt import QtGui app = pg.mkQApp("Parameter Tree Example") import pyqtgraph.parametertree.parameterTypes as pTypes -from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType +from pyqtgraph.parametertree import Parameter, ParameterTree ## test subclassing parameters @@ -62,39 +64,7 @@ class ScalableGroup(pTypes.GroupParameter): params = [ - {'name': 'Basic parameter data types', 'type': 'group', 'children': [ - {'name': 'Integer', 'type': 'int', 'value': 10}, - {'name': 'Float', 'type': 'float', 'value': 10.5, 'step': 0.1, 'finite': False}, - {'name': 'String', 'type': 'str', 'value': "hi", 'tip': 'Well hello'}, - {'name': 'Checklist', 'type': 'checklist', 'limits': [1,2,3], 'value': 2}, - {'name': 'List', 'type': 'list', 'limits': [1,2,3], 'value': 2}, - {'name': 'Named List', 'type': 'list', 'limits': {"one": 1, "two": "twosies", "three": [3,3,3]}, 'value': 2}, - {'name': 'Boolean', 'type': 'bool', 'value': True, 'tip': "This is a checkbox"}, - {'name': 'Color', 'type': 'color', 'value': "#FF0", 'tip': "This is a color button"}, - {'name': 'Gradient', 'type': 'colormap'}, - {'name': 'Subgroup', 'type': 'group', 'children': [ - {'name': 'Sub-param 1', 'type': 'int', 'value': 10}, - {'name': 'Sub-param 2', 'type': 'float', 'value': 1.2e6}, - ]}, - {'name': 'Text Parameter', 'type': 'text', 'value': 'Some text...'}, - {'name': 'Action Parameter', 'type': 'action', 'tip': 'Click me'}, - ]}, - {'name': 'Custom Parameter Options', 'type': 'group', 'children': [ - {'name': 'Pen', 'type': 'pen', 'value': pg.mkPen(color=(255,0,0), width=2)}, - {'name': 'Progress bar', 'type': 'progress', 'value':50, 'limits':(0,100)}, - {'name': 'Slider', 'type': 'slider', 'value':50, 'limits':(0,100)}, - {'name': 'Font', 'type': 'font', 'value':QtGui.QFont("Inter")}, - {'name': 'Calendar', 'type': 'calendar', 'value':QtCore.QDate.currentDate().addMonths(1)}, - {'name': 'Open python file', 'type': 'file', 'fileMode': 'ExistingFile', 'nameFilter': 'Python file (*.py);;', - 'value': 'parametertree.py', 'relativeTo': os.getcwd(), 'options': ['DontResolveSymlinks']} - ]}, - {'name': 'Numerical Parameter Options', 'type': 'group', 'children': [ - {'name': 'Units + SI prefix', 'type': 'float', 'value': 1.2e-6, 'step': 1e-6, 'siPrefix': True, 'suffix': 'V'}, - {'name': 'Limits (min=7;max=15)', 'type': 'int', 'value': 11, 'limits': (7, 15), 'default': -6}, - {'name': 'Int suffix', 'type': 'int', 'value': 9, 'suffix': 'V'}, - {'name': 'DEC stepping', 'type': 'float', 'value': 1.2e6, 'dec': True, 'step': 1, 'minStep': 1.0e-12, 'siPrefix': True, 'suffix': 'Hz'}, - - ]}, + makeAllParamTypes(), {'name': 'Save/Restore functionality', 'type': 'group', 'children': [ {'name': 'Save State', 'type': 'action'}, {'name': 'Restore State', 'type': 'action', 'children': [ @@ -102,12 +72,6 @@ params = [ {'name': 'Remove extra items', 'type': 'bool', 'value': True}, ]}, ]}, - {'name': 'Extra Parameter Options', 'type': 'group', 'children': [ - {'name': 'Read-only', 'type': 'float', 'value': 1.2e6, 'siPrefix': True, 'suffix': 'Hz', 'readonly': True}, - {'name': 'Disabled', 'type': 'float', 'value': 1.2e6, 'siPrefix': True, 'suffix': 'Hz', 'enabled': False}, - {'name': 'Renamable', 'type': 'float', 'value': 1.2e6, 'siPrefix': True, 'suffix': 'Hz', 'renamable': True}, - {'name': 'Removable', 'type': 'float', 'value': 1.2e6, 'siPrefix': True, 'suffix': 'Hz', 'removable': True}, - ]}, {'name': 'Custom context menu', 'type': 'group', 'children': [ {'name': 'List contextMenu', 'type': 'float', 'value': 0, 'context': [ 'menu1', diff --git a/pyqtgraph/configfile.py b/pyqtgraph/configfile.py index 69832710..07383193 100644 --- a/pyqtgraph/configfile.py +++ b/pyqtgraph/configfile.py @@ -43,7 +43,7 @@ def writeConfigFile(data, fname): fd.write(s) -def readConfigFile(fname): +def readConfigFile(fname, **scope): #cwd = os.getcwd() global GLOBAL_PATH if GLOBAL_PATH is not None: @@ -52,6 +52,21 @@ def readConfigFile(fname): fname = fname2 GLOBAL_PATH = os.path.dirname(os.path.abspath(fname)) + + local = {**scope, **units.allUnits} + local['OrderedDict'] = OrderedDict + local['readConfigFile'] = readConfigFile + local['Point'] = Point + local['QtCore'] = QtCore + local['ColorMap'] = ColorMap + local['datetime'] = datetime + # Needed for reconstructing numpy arrays + local['array'] = numpy.array + for dtype in ['int8', 'uint8', + 'int16', 'uint16', 'float16', + 'int32', 'uint32', 'float32', + 'int64', 'uint64', 'float64']: + local[dtype] = getattr(numpy, dtype) try: #os.chdir(newDir) ## bad. @@ -59,7 +74,7 @@ def readConfigFile(fname): s = fd.read() s = s.replace("\r\n", "\n") s = s.replace("\r", "\n") - data = parseString(s)[1] + data = parseString(s, **local)[1] except ParseError: sys.exc_info()[1].fileName = fname raise @@ -93,7 +108,7 @@ def genString(data, indent=''): s += indent + sk + ': ' + repr(data[k]).replace("\n", "\\\n") + '\n' return s -def parseString(lines, start=0): +def parseString(lines, start=0, **scope): data = OrderedDict() if isinstance(lines, str): @@ -135,33 +150,19 @@ def parseString(lines, start=0): v = v.strip() ## set up local variables to use for eval - local = units.allUnits.copy() - local['OrderedDict'] = OrderedDict - local['readConfigFile'] = readConfigFile - local['Point'] = Point - local['QtCore'] = QtCore - local['ColorMap'] = ColorMap - local['datetime'] = datetime - # Needed for reconstructing numpy arrays - local['array'] = numpy.array - for dtype in ['int8', 'uint8', - 'int16', 'uint16', 'float16', - 'int32', 'uint32', 'float32', - 'int64', 'uint64', 'float64']: - local[dtype] = getattr(numpy, dtype) - if len(k) < 1: raise ParseError('Missing name preceding colon', ln+1, l) if k[0] == '(' and k[-1] == ')': ## If the key looks like a tuple, try evaluating it. try: - k1 = eval(k, local) + k1 = eval(k, scope) if type(k1) is tuple: k = k1 except: + # If tuple conversion fails, keep the string pass if re.search(r'\S', v) and v[0] != '#': ## eval the value try: - val = eval(v, local) + val = eval(v, scope) except: ex = sys.exc_info()[1] raise ParseError("Error evaluating expression '%s': [%s: %s]" % (v, ex.__class__.__name__, str(ex)), (ln+1), l) @@ -171,7 +172,7 @@ def parseString(lines, start=0): val = {} else: #print "Going deeper..", ln+1 - (ln, val) = parseString(lines, start=ln+1) + (ln, val) = parseString(lines, start=ln+1, **scope) data[k] = val #print k, repr(val) except ParseError: diff --git a/pyqtgraph/parametertree/Parameter.py b/pyqtgraph/parametertree/Parameter.py index bc3c2315..f913a9f3 100644 --- a/pyqtgraph/parametertree/Parameter.py +++ b/pyqtgraph/parametertree/Parameter.py @@ -516,7 +516,7 @@ class Parameter(QtCore.QObject): self.setLimits(opts[k]) elif k == 'default': self.setDefault(opts[k]) - elif k not in self.opts or self.opts[k] != opts[k]: + elif k not in self.opts or not fn.eq(self.opts[k], opts[k]): self.opts[k] = opts[k] changed[k] = opts[k] diff --git a/pyqtgraph/parametertree/parameterTypes/file.py b/pyqtgraph/parametertree/parameterTypes/file.py index d456791d..951589d9 100644 --- a/pyqtgraph/parametertree/parameterTypes/file.py +++ b/pyqtgraph/parametertree/parameterTypes/file.py @@ -145,7 +145,8 @@ class FileParameterItem(StrParameterItem): startDir = os.path.dirname(startDir) if os.path.exists(startDir): opts['directory'] = startDir - opts.setdefault('windowTitle', self.param.title()) + if opts.get('windowTitle') is None: + opts['windowTitle'] = self.param.title() fname = popupFilePicker(None, **opts) if not fname: diff --git a/pyqtgraph/parametertree/parameterTypes/list.py b/pyqtgraph/parametertree/parameterTypes/list.py index 329eb1e9..0de5c3db 100644 --- a/pyqtgraph/parametertree/parameterTypes/list.py +++ b/pyqtgraph/parametertree/parameterTypes/list.py @@ -4,6 +4,7 @@ from collections import OrderedDict from .basetypes import WidgetParameterItem from .. import Parameter from ...Qt import QtWidgets +from ... import functions as fn class ListParameterItem(WidgetParameterItem): @@ -34,10 +35,12 @@ class ListParameterItem(WidgetParameterItem): def setValue(self, val): self.targetValue = val - if val not in self.reverse[0]: + match = [fn.eq(val, limVal) for limVal in self.reverse[0]] + if not any(match): self.widget.setCurrentIndex(0) else: - key = self.reverse[1][self.reverse[0].index(val)] + idx = match.index(True) + key = self.reverse[1][idx] ind = self.widget.findText(key) self.widget.setCurrentIndex(ind) @@ -104,7 +107,9 @@ class ListParameter(Parameter): self.forward, self.reverse = self.mapping(limits) Parameter.setLimits(self, limits) - if len(self.reverse[0]) > 0 and self.value() not in self.reverse[0]: + # 'value in limits' expression will break when reverse contains numpy array + curVal = self.value() + if len(self.reverse[0]) > 0 and not any(fn.eq(curVal, limVal) for limVal in self.reverse[0]): self.setValue(self.reverse[0][0]) @staticmethod @@ -112,16 +117,11 @@ class ListParameter(Parameter): # Return forward and reverse mapping objects given a limit specification forward = OrderedDict() ## {name: value, ...} reverse = ([], []) ## ([value, ...], [name, ...]) - if isinstance(limits, dict): - for k, v in limits.items(): - forward[k] = v - reverse[0].append(v) - reverse[1].append(k) - else: - for v in limits: - n = str(v) - forward[n] = v - reverse[0].append(v) - reverse[1].append(n) + if not isinstance(limits, dict): + limits = {str(l): l for l in limits} + for k, v in limits.items(): + forward[k] = v + reverse[0].append(v) + reverse[1].append(k) return forward, reverse diff --git a/pyqtgraph/parametertree/parameterTypes/slider.py b/pyqtgraph/parametertree/parameterTypes/slider.py index a085b4da..ac85edb3 100644 --- a/pyqtgraph/parametertree/parameterTypes/slider.py +++ b/pyqtgraph/parametertree/parameterTypes/slider.py @@ -33,6 +33,7 @@ class SliderParameterItem(WidgetParameterItem): def makeWidget(self): param = self.param opts = param.opts + opts.setdefault('limits', [0, 0]) self._suffix = opts.get('suffix') self.slider = QtWidgets.QSlider() @@ -94,7 +95,7 @@ class SliderParameterItem(WidgetParameterItem): span = opts.get('span', None) if span is None: step = opts.get('step', 1) - start, stop = opts['limits'] + start, stop = opts.get('limits', param.opts['limits']) # Add a bit to 'stop' since python slicing excludes the last value span = np.arange(start, stop + step, step) precision = opts.get('precision', 2)