diff --git a/param/parameterized.py b/param/parameterized.py index 9f5e57a6..78a2dda4 100644 --- a/param/parameterized.py +++ b/param/parameterized.py @@ -1151,6 +1151,14 @@ class Foo(Bar): per_instance=True ) + # Parameters can be updated during Parameterized class creation when they + # are defined multiple times in a class hierarchy. We have to record which + # Parameter slots require the default value to be re-validated. Any slots + # in this list do not have to trigger such re-validation. + _non_validated_slots = ['_label', 'doc', 'name', 'precedence', + 'constant', 'pickle_default_value', + 'watchers', 'owner'] + @typing.overload def __init__( self, @@ -3345,27 +3353,38 @@ def __param_inheritance(mcs, param_name, param): type_change = True del slots['instantiate'] - callables = {} + callables, slot_values = {}, {} + slot_overridden = False for slot in slots.keys(): - superclasses = iter(supers) - # Search up the hierarchy until param.slot (which has to - # be obtained using getattr(param,slot)) is not Undefined, or - # we run out of classes to search. - while getattr(param,slot) is Undefined: - try: - param_super_class = next(superclasses) - except StopIteration: + # be obtained using getattr(param,slot)) is not Undefined, + # is a new value (using identity) or we run out of classes + # to search. + for scls in supers: + # Class may not define parameter or slot might not be + # there because could be a more general type of Parameter + new_param = scls.__dict__.get(param_name) + if new_param is None or not hasattr(new_param, slot): + continue + + new_value = getattr(new_param, slot) + old_value = slot_values.get(slot, Undefined) + if new_value is Undefined: + continue + elif new_value is old_value: + continue + elif old_value is Undefined: + slot_values[slot] = new_value + # If we already know we have to re-validate abort + # early to avoid costly lookups + if slot_overridden or type_change: + break + else: + if slot not in param._non_validated_slots: + slot_overridden = True break - new_param = param_super_class.__dict__.get(param_name) - if new_param is not None and hasattr(new_param,slot): - # (slot might not be there because could be a more - # general type of Parameter) - new_value = getattr(new_param,slot) - if new_value is not Undefined: - setattr(param, slot, new_value) - if getattr(param, slot) is Undefined: + if slot_values.get(slot, Undefined) is Undefined: try: default_val = param._slot_defaults[slot] except KeyError as e: @@ -3376,7 +3395,11 @@ def __param_inheritance(mcs, param_name, param): if callable(default_val): callables[slot] = default_val else: - setattr(param, slot, default_val) + slot_values[slot] = default_val + + # Now set the actual slot values + for slot, value in slot_values.items(): + setattr(param, slot, value) # Avoid crosstalk between mutable slot values in different Parameter objects if slot != "default": @@ -3394,8 +3417,12 @@ def __param_inheritance(mcs, param_name, param): param._update_state() # If the type has changed to a more specific or different type - # validate the default again. - if type_change: + # or a slot value has been changed validate the default again. + + # Hack: Had to disable re-validation of None values because the + # automatic appending of an unknown value on Selector opens a whole + # rabbit hole in regard to the validation. + if type_change or slot_overridden and param.default is not None: param._validate(param.default) def get_param_descriptor(mcs,param_name): diff --git a/tests/testparameterizedobject.py b/tests/testparameterizedobject.py index 93e189e4..1a62719e 100644 --- a/tests/testparameterizedobject.py +++ b/tests/testparameterizedobject.py @@ -1167,6 +1167,36 @@ class B(A): assert "NumericTuple parameter 'B.p' only takes numeric values, not " in str(excinfo.value) +def test_inheritance_with_changing_bounds(): + class A(param.Parameterized): + p = param.Number(default=5) + + with pytest.raises(ValueError) as excinfo: + class B(A): + p = param.Number(bounds=(1, 3)) + assert "Number parameter 'p' must be at least 1, not 0.0." in str(excinfo.value) + + +def test_inheritance_with_changing_default(): + class A(param.Parameterized): + p = param.Number(default=5, bounds=(3, 10)) + + with pytest.raises(ValueError) as excinfo: + class B(A): + p = param.Number(default=1) + assert "Number parameter 'B.p' must be at least 3, not 1." in str(excinfo.value) + + +def test_inheritance_with_changing_class_(): + class A(param.Parameterized): + p = param.ClassSelector(class_=int, default=5) + + with pytest.raises(ValueError) as excinfo: + class B(A): + p = param.ClassSelector(class_=str) + assert "ClassSelector parameter 'B.p' value must be an instance of str, not 5." in str(excinfo.value) + + def test_inheritance_from_multiple_params_class(): class A(param.Parameterized): p = param.Parameter(doc='foo') diff --git a/tests/testpathparam.py b/tests/testpathparam.py index 8f37ed17..f897d4e6 100644 --- a/tests/testpathparam.py +++ b/tests/testpathparam.py @@ -18,7 +18,7 @@ def setUp(self): tmpdir1 = tempfile.mkdtemp() self.curdir = os.getcwd() - # Chanding the directory to tmpdir1 to test that Path resolves relative + # Changing the directory to tmpdir1 to test that Path resolves relative # paths to absolute paths automatically. os.chdir(tmpdir1) @@ -135,7 +135,6 @@ def test_inheritance_behavior(self): # isn't designed to be run from the tmpdir directory. startd = os.getcwd() try: - os.chdir(self.curdir) # a = param.Path() # b = param.Path(self.fb) # c = param.Path('a.txt', search_paths=[tmpdir1]) @@ -145,6 +144,8 @@ class B(self.P): b = param.Path() c = param.Path() + os.chdir(self.curdir) + assert B.a is None assert B.b == self.fb # search_paths is empty instead of [tmpdir1] and getting c raises an error