Skip to content
Open
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
3 changes: 3 additions & 0 deletions pyqtgraph/examples/_paramtreecfg.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@
}
},

'radio': {
},

'pen': {
'Pen Information': {
'type': 'str',
Expand Down
13 changes: 8 additions & 5 deletions pyqtgraph/examples/parametertree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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",
Expand Down
14 changes: 13 additions & 1 deletion pyqtgraph/parametertree/Parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 6 additions & 9 deletions pyqtgraph/parametertree/parameterTypes/checklist.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from ..Parameter import PARAM_TYPES, registerParameterItemType
from ... import functions as fn
from ...Qt import QtCore, QtWidgets
from ...SignalProxy import SignalProxy
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand Down
79 changes: 79 additions & 0 deletions tests/parametertree/test_utils.py
Original file line number Diff line number Diff line change
@@ -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"
Loading