From 67c95e440c0a876725218ded6e6d58712846246c Mon Sep 17 00:00:00 2001 From: Ashwola Date: Tue, 28 Apr 2026 14:08:30 +0200 Subject: [PATCH] application of #PR3430 --- pyqtgraph/examples/_paramtreecfg.py | 3 + pyqtgraph/examples/parametertree.py | 13 +-- pyqtgraph/parametertree/Parameter.py | 14 +++- .../parametertree/parameterTypes/checklist.py | 15 ++-- tests/parametertree/test_utils.py | 79 +++++++++++++++++++ 5 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 tests/parametertree/test_utils.py diff --git a/pyqtgraph/examples/_paramtreecfg.py b/pyqtgraph/examples/_paramtreecfg.py index 89b95dc798..9b25a09e08 100644 --- a/pyqtgraph/examples/_paramtreecfg.py +++ b/pyqtgraph/examples/_paramtreecfg.py @@ -99,6 +99,9 @@ } }, + 'radio': { + }, + 'pen': { 'Pen Information': { 'type': 'str', diff --git a/pyqtgraph/examples/parametertree.py b/pyqtgraph/examples/parametertree.py index 9bbece68d0..61b3b58b5a 100644 --- a/pyqtgraph/examples/parametertree.py +++ b/pyqtgraph/examples/parametertree.py @@ -16,15 +16,15 @@ app = pg.mkQApp("Parameter Tree Example") import pyqtgraph.parametertree.parameterTypes as pTypes -from pyqtgraph.parametertree import Parameter, ParameterTree +from pyqtgraph.parametertree import Parameter, ParameterTree, registerParameterType ## test subclassing parameters ## This parameter automatically generates two child parameters which are always reciprocals of each other class ComplexParameter(pTypes.GroupParameter): def __init__(self, **opts): - opts["type"] = "bool" - opts["value"] = True + opts['type'] = 'complexparam' + opts['value'] = True pTypes.GroupParameter.__init__(self, **opts) self.addChild( @@ -61,7 +61,7 @@ def bChanged(self): ## this group includes a menu allowing the user to add new parameters into its child list class ScalableGroup(pTypes.GroupParameter): def __init__(self, **opts): - opts["type"] = "group" + opts["type"] = "scalablegroup" opts["addText"] = "Add" # opts['addList'] = ['str', 'float', 'int'] addMenu = [ @@ -124,10 +124,13 @@ def addNew(self, typ=None): ) self.addChild(param_dict) +all_param_types = makeAllParamTypes() +registerParameterType('complexparam', ComplexParameter) +registerParameterType('scalablegroup', ScalableGroup) params = [ - makeAllParamTypes(), + all_param_types, { "name": "Save/Restore functionality", "type": "group", diff --git a/pyqtgraph/parametertree/Parameter.py b/pyqtgraph/parametertree/Parameter.py index 10ea5b538c..f43675b6c0 100644 --- a/pyqtgraph/parametertree/Parameter.py +++ b/pyqtgraph/parametertree/Parameter.py @@ -217,6 +217,18 @@ def __init__(self, **opts): self.blockTreeChangeEmit = 0 self.setName(name) + registered = PARAM_TYPES.get(self.type()) + if registered is not None and self.__class__ is not registered: + warnings.warn( + f"Parameter type '{self.type()}' is registered to " + f"{registered.__name__}, but this instance is " + f"{self.__class__.__name__}. Use registerParameterType() to register " + f"a unique type name, or this parameter may not survive a " + f"saveState()/restoreState() round-trip.", + UserWarning, + stacklevel=2, + ) + self.addChildren(self.opts.pop('children', [])) if 'value' in self.opts and 'default' not in self.opts: self.opts['default'] = self.opts['value'] @@ -312,7 +324,7 @@ def isType(self, typ): if cls is None: raise ValueError(f"Type name '{typ}' is not registered.") return self.__class__ is cls - + def childPath(self, child): """ Return the path of parameter names from self to child. diff --git a/pyqtgraph/parametertree/parameterTypes/checklist.py b/pyqtgraph/parametertree/parameterTypes/checklist.py index 8a290f9284..d8d2e1d2a5 100644 --- a/pyqtgraph/parametertree/parameterTypes/checklist.py +++ b/pyqtgraph/parametertree/parameterTypes/checklist.py @@ -1,3 +1,4 @@ +from ..Parameter import PARAM_TYPES, registerParameterItemType from ... import functions as fn from ...Qt import QtCore, QtWidgets from ...SignalProxy import SignalProxy @@ -118,15 +119,11 @@ def maybeSigChanged(self, val): self.emitter.sigChanged.emit(self, val) -# Proxy around radio/bool type so the correct item class gets instantiated -class BoolOrRadioParameter(SimpleParameter): +class RadioParameter(SimpleParameter): + itemClass = RadioParameterItem - @property - def itemClass(self): - if self.opts.get('type') == 'bool': - return BoolParameterItem - else: - return RadioParameterItem + +registerParameterItemType('radio', RadioParameterItem, RadioParameter) class ChecklistParameter(GroupParameter): @@ -207,7 +204,7 @@ def updateLimits(self, _param, limits): for chName in self.forward: # Recycle old values if they match the new limits newVal = bool(oldOpts.get(chName, False)) - child = BoolOrRadioParameter(type=typ, name=chName, value=newVal, default=None) + child = PARAM_TYPES[typ](type=typ, name=chName, value=newVal, default=None) self.addChild(child) # Prevent child from broadcasting tree state changes, since this is handled by self child.blockTreeChangeSignal() diff --git a/tests/parametertree/test_utils.py b/tests/parametertree/test_utils.py new file mode 100644 index 0000000000..d5afe32073 --- /dev/null +++ b/tests/parametertree/test_utils.py @@ -0,0 +1,79 @@ +"""Tests for custom Parameter subclass save/restore fidelity (issue #3430).""" +import warnings + +import pytest +import pyqtgraph.parametertree.parameterTypes as pTypes +from pyqtgraph.parametertree import Parameter, registerParameterType +from pyqtgraph.parametertree.Parameter import PARAM_NAMES, PARAM_TYPES +from pyqtgraph.functions import eq + + +@pytest.fixture(autouse=True) +def _restore_param_registry(): + """Isolate each test: restore PARAM_TYPES/PARAM_NAMES to their pre-test state.""" + saved_types = dict(PARAM_TYPES) + saved_names = dict(PARAM_NAMES) + yield + PARAM_TYPES.clear() + PARAM_TYPES.update(saved_types) + PARAM_NAMES.clear() + PARAM_NAMES.update(saved_names) + + +def _classes(p): + """Recursive class fingerprint: [type, [children...]].""" + return [type(p), [_classes(c) for c in p.children()]] + + +def test_custom_subclass_survives_round_trip(): + """A registered custom subclass must be re-created (not its base) after restoreState.""" + class MyGroup(pTypes.GroupParameter): + def __init__(self, **opts): + opts['type'] = 'mygroup' + super().__init__(**opts) + + registerParameterType('mygroup', MyGroup) + + original = MyGroup(name='root', children=[ + dict(name='x', type='int', value=3), + ]) + state = original.saveState() + restored = Parameter.create(**state) + + assert type(restored) is MyGroup, ( + f"Expected MyGroup after restoreState, got {type(restored).__name__}" + ) + assert eq(state, restored.saveState()) + assert _classes(original) == _classes(restored) + + +def test_unregistered_subclass_warns(): + """A subclass that reuses a built-in type name should emit UserWarning.""" + class BadSub(pTypes.GroupParameter): + def __init__(self, **opts): + opts['type'] = 'group' # reuses built-in type + super().__init__(**opts) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + BadSub(name='bad') + + assert any(issubclass(warning.category, UserWarning) for warning in w), \ + "Expected a UserWarning for type/class mismatch" + + +def test_registered_subclass_no_warning(): + """A properly registered subclass must not produce any warning.""" + class GoodSub(pTypes.GroupParameter): + def __init__(self, **opts): + opts['type'] = 'goodsub' + super().__init__(**opts) + + registerParameterType('goodsub', GoodSub) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + GoodSub(name='good') + + assert not any(issubclass(warning.category, UserWarning) for warning in w), \ + "Unexpected UserWarning for correctly registered subclass"