From b9bee1343ca7219a77dc7cba98e252a45f10fe4e Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 31 Jan 2022 18:33:07 +0100 Subject: [PATCH 01/12] Add SelectorObjects wrapper for forward and backward compatibility --- param/__init__.py | 148 +++++++++++++++++++++++++++++++++++++---- param/parameterized.py | 4 +- 2 files changed, 137 insertions(+), 15 deletions(-) diff --git a/param/__init__.py b/param/__init__.py index 99ccc861d..2b2ededce 100644 --- a/param/__init__.py +++ b/param/__init__.py @@ -25,6 +25,10 @@ import datetime as dt import collections +from collections import OrderedDict +from contextlib import contextmanager +from numbers import Real + from .parameterized import ( Parameterized, Parameter, String, ParameterizedFunction, ParamOverrides, descendents, get_logger, instance_descriptor, basestring, dt_types) @@ -35,8 +39,6 @@ from .parameterized import logging_level # noqa: api import from .parameterized import DEBUG, VERBOSE, INFO, WARNING, ERROR, CRITICAL # noqa: api import -from collections import OrderedDict -from numbers import Real # Determine up-to-date version information, if possible, but with a # safe fallback to ensure that this file and parameterized.py are the @@ -1201,6 +1203,117 @@ def get_range(self): raise NotImplementedError("get_range() must be implemented in subclasses.") + +class SelectObjects(list): + + def __init__(self, iterable, parameter=None): + super(SelectObjects, self).__init__(iterable) + self._parameter = parameter + + def _warn(self, method): + clsname = type(self._parameter).__name__ + get_logger().warning( + '{clsname}.objects{method} is deprecated if objects attribute ' + 'was declared as a dictionary. Use `{clsname}.objects[label] ' + '= value` instead.'.format(clsname=clsname, method=method) + ) + + @contextmanager + def _trigger(self, trigger=True): + old = dict(self._parameter.names) or list(self._parameter._objects) + yield + if trigger: + value = self._parameter.names or self._parameter._objects + self._parameter._trigger_event('objects', old, value) + + def append(self, object): + if self._parameter.names: + self._warn('.append') + with self._trigger(): + super(SelectObjects, self).append(object) + self._parameter._objects.append(object) + + def clear(self): + with self._trigger(): + super(SelectObjects, self).clear() + self._parameter._objects.clear() + self._parameter.names.clear() + + def extend(self, objects): + if self._parameter.names: + self._warn('.append') + with self._trigger(): + super(SelectObjects, self).extend(objects) + self._parameter._objects.extend(objects) + + def insert(self, index, object): + if self._parameter.names: + self._warn('.insert') + with self._trigger(): + super(SelectObjects, self).insert(index, object) + self._parameter._objects.insert(index, object) + + def pop(self, *args): + index = args[0] if args else -1 + if isinstance(index, int): + with self._trigger(): + super(SelectObjects, self).pop(index) + object = self._parameter._objects.pop(index) + if self._parameter.names: + self._parameter.names = { + k: v for k, v in self._parameter.names.items() + if v is object + } + return + if self and not self._parameter.names: + raise ValueError( + 'Cannot pop an object from {clsname}.objects if ' + 'objects was not declared as a dictionary.' + ) + with self._trigger(): + object = self._parameter.names.pop(*args) + super(SelectObjects, self).remove(object) + self._parameter._objects.remove(object) + return object + + def remove(self, object): + with self._trigger(): + super(SelectObjects, self).pop(object) + self._parameter._objects.remove(object) + if self._parameter.names: + self._parameter.names = { + k: v for k, v in self._parameter.names.items() + if v is object + } + + def __setitem__(self, index, object, trigger=True): + if isinstance(index, int): + if self._parameter.names: + self._warn('[index] = object') + with self._trigger(): + super(SelectObjects, self).__setitem__(index, object) + self._parameter.objects[index] = object + return + clsname = type(self._parameter).__name__ + if self and not self._parameter.names: + raise ValueError( + 'Cannot assign new objects to {clsname}.objects by name if ' + 'objects was not declared as a dictionary.'.format(clsname=clsname) + ) + with self._trigger(trigger): + super(SelectObjects, self).append(object) + self._parameter._objects.append(object) + self._parameter.names[index] = object + + def update(self, objects, **items): + objects = objects.items() if isinstance(objects, dict) else objects + with self._trigger(): + for k, v in objects: + self.__setitem__(k, v, trigger=False) + for k, v in items.items(): + self.__setitem__(k, v, trigger=False) + + class Selector(SelectorBase): """ Parameter whose value must be one object from a list of possible objects. @@ -1228,7 +1341,7 @@ class Selector(SelectorBase): up from the object value. """ - __slots__ = ['objects', 'compute_default_fn', 'check_on_set', 'names'] + __slots__ = ['_objects', 'compute_default_fn', 'check_on_set', 'names'] # Selector is usually used to allow selection from a list of # existing objects, therefore instantiate is False by default. @@ -1253,12 +1366,7 @@ def __init__(self, objects=None, default=None, instantiate=False, if objects is None: objects = [] - if isinstance(objects, collections_abc.Mapping): - self.names = objects - self.objects = list(objects.values()) - else: - self.names = None - self.objects = objects + self.objects = objects self.compute_default_fn = compute_default_fn if check_on_set is not None: @@ -1275,6 +1383,19 @@ def __init__(self, objects=None, default=None, instantiate=False, if default is not None and self.check_on_set is True: self._validate(default) + @property + def objects(self): + return SelectObjects(self._objects, self) + + @objects.setter + def objects(self, objects): + if isinstance(objects, collections_abc.Mapping): + self.names = objects + self._objects = list(objects.values()) + else: + self.names = {} + self._objects = objects + # Note that if the list of objects is changed, the current value for # this parameter in existing POs could be outside of the new range. @@ -1322,22 +1443,22 @@ def _validate(self, val): raise ValueError("%s not in parameter%s's list of possible objects, " "valid options include %s" % (val, attrib_name, items)) - def _ensure_value_is_in_objects(self,val): + def _ensure_value_is_in_objects(self, val): """ Make sure that the provided value is present on the objects list. Subclasses can override if they support multiple items on a list, to check each item instead. """ if not (val in self.objects): - self.objects.append(val) + self._objects.append(val) def get_range(self): """ Return the possible objects to which this parameter could be set. - (Returns the dictionary {object.name:object}.) + (Returns the dictionary {object.name: object}.) """ - return named_objs(self.objects, self.names) + return named_objs(self._objects, self.names) class ObjectSelector(Selector): @@ -2297,7 +2418,6 @@ def __set__(self, obj, val): self._reset_event(obj, val) -from contextlib import contextmanager @contextmanager def exceptions_summarized(): """Useful utility for writing docs that need to show expected errors. diff --git a/param/parameterized.py b/param/parameterized.py index 607e01f94..b7e9cf303 100644 --- a/param/parameterized.py +++ b/param/parameterized.py @@ -1134,9 +1134,11 @@ def __setattr__(self, attribute, value): if old is NotImplemented: return + self._trigger_event(attribute, old, value) + def _trigger_event(self, attribute, old, new): event = Event(what=attribute, name=self.name, obj=None, cls=self.owner, - old=old, new=value, type=None) + old=old, new=new, type=None) for watcher in self.watchers[attribute]: self.owner.param._call_watcher(watcher, event) if not self.owner.param._BATCH_WATCH: From 84dd177cb4a9bfd5e270158beb1def12e11e4da8 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 1 Feb 2022 13:25:37 +0100 Subject: [PATCH 02/12] Special handling for objects attribute --- param/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/param/__init__.py b/param/__init__.py index 2b2ededce..0f0fcb466 100644 --- a/param/__init__.py +++ b/param/__init__.py @@ -1205,6 +1205,11 @@ def get_range(self): class SelectObjects(list): + """ + Wrapper around Selector.objects which allows both list-like and + dictionary like updates to the set of objects, ensuring backward + compatibility for setting objects as lists and dictionaries. + """ def __init__(self, iterable, parameter=None): super(SelectObjects, self).__init__(iterable) @@ -1220,6 +1225,7 @@ def _warn(self, method): @contextmanager def _trigger(self, trigger=True): + trigger = 'objects' in self._parameter.watchers and trigger old = dict(self._parameter.names) or list(self._parameter._objects) yield if trigger: From 4066393b6ab460fc9e8fee8051d4c370c89d4a1f Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sat, 7 Jan 2023 15:33:52 +0100 Subject: [PATCH 03/12] Write tests --- param/__init__.py | 57 +++++++-- tests/API1/testobjectselector.py | 209 ++++++++++++++++++++++++++++++- 2 files changed, 253 insertions(+), 13 deletions(-) diff --git a/param/__init__.py b/param/__init__.py index 0f0fcb466..0bf44b9f3 100644 --- a/param/__init__.py +++ b/param/__init__.py @@ -1284,37 +1284,70 @@ def pop(self, *args): def remove(self, object): with self._trigger(): - super(SelectObjects, self).pop(object) + super(SelectObjects, self).remove(object) self._parameter._objects.remove(object) if self._parameter.names: - self._parameter.names = { - k: v for k, v in self._parameter.names.items() - if v is object - } + copy = self._parameter.names.copy() + self._parameter.names.clear() + self._parameter.names.update({ + k: v for k, v in copy.items() if v is not object + }) def __setitem__(self, index, object, trigger=True): - if isinstance(index, int): + if isinstance(index, (int, slice)): if self._parameter.names: self._warn('[index] = object') with self._trigger(): super(SelectObjects, self).__setitem__(index, object) - self._parameter.objects[index] = object + self._parameter._objects[index] = object return clsname = type(self._parameter).__name__ if self and not self._parameter.names: - raise ValueError( + raise TypeError( 'Cannot assign new objects to {clsname}.objects by name if ' - 'objects was not declared as a dictionary.'.format(clsname=clsname) + 'it was not declared as a dictionary.'.format(clsname=clsname) ) with self._trigger(trigger): - super(SelectObjects, self).append(object) - self._parameter._objects.append(object) + if index in self._parameter.names: + old = self._parameter.names[index] + idx = self.index(old) + super(SelectObjects, self).__setitem__(idx, object) + self._parameter._objects[idx] = object + else: + super(SelectObjects, self).append(object) + self._parameter._objects.append(object) self._parameter.names[index] = object + def __eq__(self, other): + eq = super().__eq__(other) + if self._parameter.names and eq is NotImplemented: + return dict(zip(self._parameter.names, self)) == other + return eq + + def __ne__(self, other): + return not self.__eq__(other) + def update(self, objects, **items): + clsname = type(self._parameter).__name__ + if not self._parameter.names: + raise ValueError( + 'Cannot update {clsname}.objects if it was not declared ' + 'as a dictionary.'.format(clsname=clsname) + ) objects = objects.items() if isinstance(objects, dict) else objects with self._trigger(): - for k, v in objects: + for i, o in enumerate(objects): + if not isinstance(o, collections_abc.Sequence): + raise TypeError( + 'cannot convert dictionary update sequence element #{i} to a sequence'.format(i=i) + ) + o = tuple(o) + n = len(o) + if n != 2: + raise ValueError( + 'dictionary update sequence element #{i} has length {n}; 2 is required'.format(i=i, n=n) + ) + k, v = o self.__setitem__(k, v, trigger=False) for k, v in items.items(): self.__setitem__(k, v, trigger=False) diff --git a/tests/API1/testobjectselector.py b/tests/API1/testobjectselector.py index a4a3c8c83..1b3694840 100644 --- a/tests/API1/testobjectselector.py +++ b/tests/API1/testobjectselector.py @@ -26,6 +26,16 @@ class P(param.Parameterized): s = param.Selector(default=3,objects=OrderedDict(one=1,two=2,three=3)) d = param.Selector(default=opts['B'],objects=opts) + changes = [] + + @param.depends('e:objects', watch=True) + def track_e_objects(self): + self.changes.append(('e', list(self.param.e.objects))) + + @param.depends('s:objects', watch=True) + def track_s_objects(self): + self.changes.append(('s', list(self.param.s.objects))) + self.P = P def test_set_object_constructor(self): @@ -95,6 +105,204 @@ def test_set_object_setattr_post_error(self): p.i = 12 self.assertEqual(p.i, 12) + def test_change_objects_list(self): + p = self.P() + p.param.e.objects = [8, 9] + + with self.assertRaises(ValueError): + p.e = 7 + + self.assertEqual(p.param.e.objects, [8, 9]) + self.assertEqual(p.changes, [('e', [8, 9])]) + + def test_append_objects_list(self): + p = self.P() + p.param.e.objects.append(8) + + p.e = 8 + + self.assertEqual(p.param.e.objects, [5, 6, 7, 8]) + self.assertEqual(p.changes, [('e', [5, 6, 7, 8])]) + + def test_extend_objects_list(self): + p = self.P() + p.param.e.objects.extend([8, 9]) + + p.e = 8 + + self.assertEqual(p.param.e.objects, [5, 6, 7, 8, 9]) + self.assertEqual(p.changes, [('e', [5, 6, 7, 8, 9])]) + + def test_insert_objects_list(self): + p = self.P() + p.param.e.objects.insert(0, 8) + + p.e = 8 + + self.assertEqual(p.param.e.objects, [8, 5, 6, 7]) + self.assertEqual(p.changes, [('e', [8, 5, 6, 7])]) + + def test_pop_objects_list(self): + p = self.P() + p.param.e.objects.pop(-1) + + with self.assertRaises(ValueError): + p.e = 7 + + self.assertEqual(p.param.e.objects, [5, 6]) + self.assertEqual(p.changes, [('e', [5, 6])]) + + def test_remove_objects_list(self): + p = self.P() + p.param.e.objects.remove(7) + + with self.assertRaises(ValueError): + p.e = 7 + + self.assertEqual(p.param.e.objects, [5, 6]) + self.assertEqual(p.changes, [('e', [5, 6])]) + + def test_clear_objects_list(self): + p = self.P() + p.param.e.objects.clear() + + with self.assertRaises(ValueError): + p.e = 5 + + self.assertEqual(p.param.e.objects, []) + self.assertEqual(p.changes, [('e', [])]) + + def test_clear_setitem_objects_list(self): + p = self.P() + p.param.e.objects[:] = [] + + with self.assertRaises(ValueError): + p.e = 5 + + self.assertEqual(p.param.e.objects, []) + self.assertEqual(p.changes, [('e', [])]) + + def test_override_setitem_objects_list(self): + p = self.P() + p.param.e.objects[0] = 8 + + with self.assertRaises(ValueError): + p.e = 5 + + p.e = 8 + + self.assertEqual(p.param.e.objects, [8, 6, 7]) + self.assertEqual(p.changes, [('e', [8, 6, 7])]) + + def test_setitem_name_objects_list(self): + p = self.P() + + with self.assertRaises(TypeError): + p.param.e.objects['A'] = 8 + + def test_int_getitem_objects_list(self): + p = self.P() + + self.assertEqual(p.param.e.objects[0], 5) + + def test_slice_getitem_objects_list(self): + p = self.P() + + self.assertEqual(p.param.e.objects[1:3], [6, 7]) + + def test_change_objects_dict(self): + p = self.P() + p.param.s.objects = {'seven': 7, 'eight': 8} + + with self.assertRaises(ValueError): + p.s = 1 + + self.assertEqual(p.param.s.objects, [7, 8]) + self.assertEqual(p.changes, [('s', [7, 8])]) + + def test_setitem_int_objects_dict(self): + p = self.P() + with self.assertRaises(TypeError): + p.param.s.objects[2] = 7 + + def test_setitem_key_objects_dict(self): + p = self.P() + p.param.s.objects['seven'] = 7 + + p.s = 7 + + self.assertEqual(p.param.s.objects, [1, 2, 3, 7]) + self.assertEqual(p.changes, [('s', [1, 2, 3, 7])]) + + def test_objects_dict_equality(self): + p = self.P() + p.param.s.objects = {'seven': 7, 'eight': 8} + + self.assertEqual(p.param.s.objects, {'seven': 7, 'eight': 8}) + self.assertNotEqual(p.param.s.objects, {'seven': 7, 'eight': 8, 'nine': 9}) + + def test_clear_objects_dict(self): + p = self.P() + p.param.s.objects.clear() + + with self.assertRaises(ValueError): + p.s = 1 + + self.assertEqual(p.param.s.objects, []) + self.assertEqual(p.changes, [('s', [])]) + + def test_pop_objects_dict(self): + p = self.P() + p.param.s.objects.pop('one') + + with self.assertRaises(ValueError): + p.s = 1 + + self.assertEqual(p.param.s.objects, [2, 3]) + self.assertEqual(p.changes, [('s', [2, 3])]) + + def test_remove_objects_dict(self): + p = self.P() + p.param.s.objects.remove(1) + + with self.assertRaises(ValueError): + p.s = 1 + + self.assertEqual(p.param.s.objects, [2, 3]) + self.assertEqual(p.param.s.names, {'two': 2, 'three': 3}) + self.assertEqual(p.changes, [('s', [2, 3])]) + + def test_update_objects_dict(self): + p = self.P() + p.param.s.objects.update({'one': '1', 'three': '3'}) + + with self.assertRaises(ValueError): + p.s = 1 + + p.s = '3' + + self.assertEqual(p.param.s.objects, ['1', 2, '3']) + self.assertEqual(p.changes, [('s', ['1', 2, '3'])]) + + def test_update_with_list_objects_dict(self): + p = self.P() + p.param.s.objects.update([('one', '1'), ('three', '3')]) + + with self.assertRaises(ValueError): + p.s = 1 + + p.s = '3' + + self.assertEqual(p.param.s.objects, ['1', 2, '3']) + self.assertEqual(p.changes, [('s', ['1', 2, '3'])]) + + def test_update_with_invalid_list_objects_dict(self): + p = self.P() + with self.assertRaises(TypeError): + p.param.s.objects.update([1, 3]) + with self.assertRaises(ValueError): + p.param.s.objects.update(['a', 'b']) + def test_initialization_out_of_bounds(self): try: class Q(param.Parameterized): @@ -104,7 +312,6 @@ class Q(param.Parameterized): else: raise AssertionError("ObjectSelector created outside range.") - def test_initialization_no_bounds(self): try: class Q(param.Parameterized): From 058080ab5a9aaea15513b7c1416967e2a40db19b Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 9 Jan 2023 12:15:07 +0100 Subject: [PATCH 04/12] Add more tests --- param/__init__.py | 95 ++++++++++++++++++++------------ tests/API1/testobjectselector.py | 64 ++++++++++++++++++++- 2 files changed, 122 insertions(+), 37 deletions(-) diff --git a/param/__init__.py b/param/__init__.py index 0bf44b9f3..6739dbc39 100644 --- a/param/__init__.py +++ b/param/__init__.py @@ -1231,6 +1231,44 @@ def _trigger(self, trigger=True): if trigger: value = self._parameter.names or self._parameter._objects self._parameter._trigger_event('objects', old, value) + def __getitem__(self, index): + if self._parameter.names: + return self._parameter.names[index] + return super(SelectObjects, self).__getitem__(index) + + def __setitem__(self, index, object, trigger=True): + if isinstance(index, (int, slice)): + if self._parameter.names: + self._warn('[index] = object') + with self._trigger(): + super(SelectObjects, self).__setitem__(index, object) + self._parameter._objects[index] = object + return + clsname = type(self._parameter).__name__ + if self and not self._parameter.names: + raise TypeError( + 'Cannot assign new objects to {clsname}.objects by name if ' + 'it was not declared as a dictionary.'.format(clsname=clsname) + ) + with self._trigger(trigger): + if index in self._parameter.names: + old = self._parameter.names[index] + idx = self.index(old) + super(SelectObjects, self).__setitem__(idx, object) + self._parameter._objects[idx] = object + else: + super(SelectObjects, self).append(object) + self._parameter._objects.append(object) + self._parameter.names[index] = object + + def __eq__(self, other): + eq = super().__eq__(other) + if self._parameter.names and eq is NotImplemented: + return dict(zip(self._parameter.names, self)) == other + return eq + + def __ne__(self, other): + return not self.__eq__(other) def append(self, object): if self._parameter.names: @@ -1239,6 +1277,11 @@ def append(self, object): super(SelectObjects, self).append(object) self._parameter._objects.append(object) + def copy(self): + if self._parameter.names: + return self._parameter.names.copy() + return list(self) + def clear(self): with self._trigger(): super(SelectObjects, self).clear() @@ -1252,6 +1295,11 @@ def extend(self, objects): super(SelectObjects, self).extend(objects) self._parameter._objects.extend(objects) + def get(self, key, default=None): + if self._parameter.names: + return self._parameter.names.get(key, default) + return default + def insert(self, index, object): if self._parameter.names: self._warn('.insert') @@ -1259,6 +1307,14 @@ def insert(self, index, object): super(SelectObjects, self).insert(index, object) self._parameter._objects.insert(index, object) + def items(self): + if self._parameter.names: + return self._parameter.names.items() + + def keys(self): + if self._parameter.names: + return self._parameter.names.keys() + def pop(self, *args): index = args[0] if args else -1 if isinstance(index, int): @@ -1293,40 +1349,6 @@ def remove(self, object): k: v for k, v in copy.items() if v is not object }) - def __setitem__(self, index, object, trigger=True): - if isinstance(index, (int, slice)): - if self._parameter.names: - self._warn('[index] = object') - with self._trigger(): - super(SelectObjects, self).__setitem__(index, object) - self._parameter._objects[index] = object - return - clsname = type(self._parameter).__name__ - if self and not self._parameter.names: - raise TypeError( - 'Cannot assign new objects to {clsname}.objects by name if ' - 'it was not declared as a dictionary.'.format(clsname=clsname) - ) - with self._trigger(trigger): - if index in self._parameter.names: - old = self._parameter.names[index] - idx = self.index(old) - super(SelectObjects, self).__setitem__(idx, object) - self._parameter._objects[idx] = object - else: - super(SelectObjects, self).append(object) - self._parameter._objects.append(object) - self._parameter.names[index] = object - - def __eq__(self, other): - eq = super().__eq__(other) - if self._parameter.names and eq is NotImplemented: - return dict(zip(self._parameter.names, self)) == other - return eq - - def __ne__(self, other): - return not self.__eq__(other) - def update(self, objects, **items): clsname = type(self._parameter).__name__ if not self._parameter.names: @@ -1352,6 +1374,11 @@ def update(self, objects, **items): for k, v in items.items(): self.__setitem__(k, v, trigger=False) + def values(self): + if self._parameter.names: + return self._parameter.names.values() + return list(self) + class Selector(SelectorBase): """ diff --git a/tests/API1/testobjectselector.py b/tests/API1/testobjectselector.py index 1b3694840..e3e9ae2ab 100644 --- a/tests/API1/testobjectselector.py +++ b/tests/API1/testobjectselector.py @@ -115,6 +115,14 @@ def test_change_objects_list(self): self.assertEqual(p.param.e.objects, [8, 9]) self.assertEqual(p.changes, [('e', [8, 9])]) + def test_copy_objects_list(self): + p = self.P() + eobjs = p.param.e.objects.copy() + + self.assertIsInstance(eobjs, list) + self.assertFalse(eobjs is p.param.e.objects) + self.assertEqual(eobjs, [5, 6, 7]) + def test_append_objects_list(self): p = self.P() p.param.e.objects.append(8) @@ -133,6 +141,10 @@ def test_extend_objects_list(self): self.assertEqual(p.param.e.objects, [5, 6, 7, 8, 9]) self.assertEqual(p.changes, [('e', [5, 6, 7, 8, 9])]) + def test_get_objects_list(self): + p = self.P() + self.assertEqual(p.param.e.objects.get(5, 'five'), 'five') + def test_insert_objects_list(self): p = self.P() p.param.e.objects.insert(0, 8) @@ -209,6 +221,11 @@ def test_slice_getitem_objects_list(self): p = self.P() self.assertEqual(p.param.e.objects[1:3], [6, 7]) + + def test_values_objects_list(self): + p = self.P() + + self.assertEqual(p.param.e.objects.values(), list(p.param.e.objects)) def test_change_objects_dict(self): p = self.P() @@ -220,10 +237,31 @@ def test_change_objects_dict(self): self.assertEqual(p.param.s.objects, [7, 8]) self.assertEqual(p.changes, [('s', [7, 8])]) - def test_setitem_int_objects_dict(self): + def test_getitem_int_objects_dict(self): + p = self.P() + with self.assertRaises(KeyError): + p.param.s.objects[2] + + def test_getitem_objects_dict(self): + p = self.P() + self.assertEqual(p.param.s.objects['two'], 2) + + def test_keys_objects_dict(self): + p = self.P() + self.assertEqual(list(p.param.s.objects.keys()), ['one', 'two', 'three']) + + def test_items_objects_dict(self): p = self.P() - with self.assertRaises(TypeError): - p.param.s.objects[2] = 7 + + self.assertEqual(list(p.param.s.objects.items()), [('one', 1), ('two', 2), ('three', 3)]) + + def test_cast_to_dict_objects_dict(self): + p = self.P() + self.assertEqual(dict(p.param.s.objects), {'one': 1, 'two': 2, 'three': 3}) + + def test_cast_to_list_objects_dict(self): + p = self.P() + self.assertEqual(list(p.param.s.objects), [1, 2, 3]) def test_setitem_key_objects_dict(self): p = self.P() @@ -251,6 +289,21 @@ def test_clear_objects_dict(self): self.assertEqual(p.param.s.objects, []) self.assertEqual(p.changes, [('s', [])]) + def test_copy_objects_dict(self): + p = self.P() + sobjs = p.param.s.objects.copy() + + self.assertIsInstance(sobjs, dict) + self.assertEqual(sobjs, {'one': 1, 'two': 2, 'three': 3}) + + def test_get_objects_dict(self): + p = self.P() + self.assertEqual(p.param.s.objects.get('two'), 2) + + def test_get_default_objects_dict(self): + p = self.P() + self.assertEqual(p.param.s.objects.get('four', 'four'), 'four') + def test_pop_objects_dict(self): p = self.P() p.param.s.objects.pop('one') @@ -303,6 +356,11 @@ def test_update_with_invalid_list_objects_dict(self): with self.assertRaises(ValueError): p.param.s.objects.update(['a', 'b']) + def test_values_objects_dict(self): + p = self.P() + + self.assertEqual(list(p.param.s.objects.values()), [1, 2, 3]) + def test_initialization_out_of_bounds(self): try: class Q(param.Parameterized): From d57719000ef62f3ed34a1f145af4ca51bc63dc4b Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 9 Jan 2023 12:34:26 +0100 Subject: [PATCH 05/12] Allow list selector to behave like dictionary --- param/__init__.py | 17 +++++++---------- tests/API1/testobjectselector.py | 29 +++++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/param/__init__.py b/param/__init__.py index 6739dbc39..364f6595e 100644 --- a/param/__init__.py +++ b/param/__init__.py @@ -1231,6 +1231,7 @@ def _trigger(self, trigger=True): if trigger: value = self._parameter.names or self._parameter._objects self._parameter._trigger_event('objects', old, value) + def __getitem__(self, index): if self._parameter.names: return self._parameter.names[index] @@ -1246,10 +1247,7 @@ def __setitem__(self, index, object, trigger=True): return clsname = type(self._parameter).__name__ if self and not self._parameter.names: - raise TypeError( - 'Cannot assign new objects to {clsname}.objects by name if ' - 'it was not declared as a dictionary.'.format(clsname=clsname) - ) + self._parameter.names = named_objs(self) with self._trigger(trigger): if index in self._parameter.names: old = self._parameter.names[index] @@ -1298,7 +1296,7 @@ def extend(self, objects): def get(self, key, default=None): if self._parameter.names: return self._parameter.names.get(key, default) - return default + return named_objs(self).get(key, default) def insert(self, index, object): if self._parameter.names: @@ -1310,10 +1308,12 @@ def insert(self, index, object): def items(self): if self._parameter.names: return self._parameter.names.items() + return named_objs(self).items() def keys(self): if self._parameter.names: return self._parameter.names.keys() + return named_objs(self).keys() def pop(self, *args): index = args[0] if args else -1 @@ -1352,10 +1352,7 @@ def remove(self, object): def update(self, objects, **items): clsname = type(self._parameter).__name__ if not self._parameter.names: - raise ValueError( - 'Cannot update {clsname}.objects if it was not declared ' - 'as a dictionary.'.format(clsname=clsname) - ) + self._parameter.names = named_objs(self) objects = objects.items() if isinstance(objects, dict) else objects with self._trigger(): for i, o in enumerate(objects): @@ -1377,7 +1374,7 @@ def update(self, objects, **items): def values(self): if self._parameter.names: return self._parameter.names.values() - return list(self) + return named_objs(self).values() class Selector(SelectorBase): diff --git a/tests/API1/testobjectselector.py b/tests/API1/testobjectselector.py index e3e9ae2ab..62def8ad1 100644 --- a/tests/API1/testobjectselector.py +++ b/tests/API1/testobjectselector.py @@ -143,6 +143,7 @@ def test_extend_objects_list(self): def test_get_objects_list(self): p = self.P() + self.assertEqual(p.param.e.objects.get('5'), 5) self.assertEqual(p.param.e.objects.get(5, 'five'), 'five') def test_insert_objects_list(self): @@ -209,8 +210,18 @@ def test_override_setitem_objects_list(self): def test_setitem_name_objects_list(self): p = self.P() - with self.assertRaises(TypeError): - p.param.e.objects['A'] = 8 + p.param.e.objects['A'] = 8 + + self.assertEqual(p.param.e.objects, {'5': 5, '6': 6, '7': 7, 'A': 8}) + self.assertEqual(len(p.changes), 1) + + def test_update_objects_list(self): + p = self.P() + + p.param.e.objects.update({'A': 8}) + + self.assertEqual(p.param.e.objects, {'5': 5, '6': 6, '7': 7, 'A': 8}) + self.assertEqual(len(p.changes), 1) def test_int_getitem_objects_list(self): p = self.P() @@ -222,11 +233,21 @@ def test_slice_getitem_objects_list(self): self.assertEqual(p.param.e.objects[1:3], [6, 7]) + def test_items_objects_list(self): + p = self.P() + + self.assertEqual(list(p.param.e.objects.items()), [('5', 5), ('6', 6), ('7', 7)]) + + def test_keys_objects_list(self): + p = self.P() + + self.assertEqual(list(p.param.e.objects.keys()), ['5', '6', '7']) + def test_values_objects_list(self): p = self.P() - self.assertEqual(p.param.e.objects.values(), list(p.param.e.objects)) - + self.assertEqual(list(p.param.e.objects.values()), list(p.param.e.objects)) + def test_change_objects_dict(self): p = self.P() p.param.s.objects = {'seven': 7, 'eight': 8} From 1700558b41625667408b9942719f52818aecb509 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 9 Jan 2023 12:39:02 +0100 Subject: [PATCH 06/12] Add docs --- examples/user_guide/Parameter_Types.ipynb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/user_guide/Parameter_Types.ipynb b/examples/user_guide/Parameter_Types.ipynb index fd2ba8a49..99a19812c 100644 --- a/examples/user_guide/Parameter_Types.ipynb +++ b/examples/user_guide/Parameter_Types.ipynb @@ -715,7 +715,7 @@ "outputs": [], "source": [ "with param.exceptions_summarized():\n", - " p.d='Parameter_Types.ipynb'" + " p.d = 'Parameter_Types.ipynb'" ] }, { @@ -737,6 +737,8 @@ "\n", "Otherwise, the objects should be provided as a _name_:_value_ dictionary, where the string name will be stored for use in such a UI, but is not otherwise accessed by Param. The values from setting and getting the parameter are always the actual underlying object, not the string names. Because the string name will need to be looked up from the value if this parameter is used in a UI, all objects need to be hashable via the `param.hashable()` function, which accepts Python literals plus list and dictionary types (treating them like tuples).\n", "\n", + "To make it easier to modify `objects` they are wrapped in a object that supports both list and dictionary style methods. This ensures that there is a consistent API for updating the `objects` and that modifying the objects in-place still triggers an event.\n", + "\n", "If the list of available objects is not meant be exhaustive, you can specify `check_on_set=False` (which automatically applies if the initial list is empty). Objects will then be added to the `objects` list whenever they are set, including as the initial default. `check_on_set=False` can be useful when the predefined set of objects is not exhaustive, letting a user select from the existing list for convenience while also being able to supply any other suitable object they can construct. When `check_on_set=True`, the initial value (and all subsequent values) must be in the `objects` list.\n", "\n", "Because `Selector` is usually used to allow selection from a list of existing (instantiated) objects, `instantiate` is False by default, but you can specify `instantiate=True` if you want each copy of this Parameter value to be independent of other instances and superclasses.\n", From 8fa743079e0944550abf332fb761415375cc624b Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 9 Jan 2023 16:01:06 +0100 Subject: [PATCH 07/12] Fix flakes --- param/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/param/__init__.py b/param/__init__.py index 364f6595e..7775e6ba3 100644 --- a/param/__init__.py +++ b/param/__init__.py @@ -1245,7 +1245,6 @@ def __setitem__(self, index, object, trigger=True): super(SelectObjects, self).__setitem__(index, object) self._parameter._objects[index] = object return - clsname = type(self._parameter).__name__ if self and not self._parameter.names: self._parameter.names = named_objs(self) with self._trigger(trigger): @@ -1350,7 +1349,6 @@ def remove(self, object): }) def update(self, objects, **items): - clsname = type(self._parameter).__name__ if not self._parameter.names: self._parameter.names = named_objs(self) objects = objects.items() if isinstance(objects, dict) else objects From e5618c7c6138c547a5177398f955a867d27f2ac1 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Mon, 9 Jan 2023 16:18:05 +0100 Subject: [PATCH 08/12] Fix py2 compat --- param/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/param/__init__.py b/param/__init__.py index 7775e6ba3..b200bfa91 100644 --- a/param/__init__.py +++ b/param/__init__.py @@ -1259,7 +1259,7 @@ def __setitem__(self, index, object, trigger=True): self._parameter.names[index] = object def __eq__(self, other): - eq = super().__eq__(other) + eq = super(SelectObjects, self).__eq__(other) if self._parameter.names and eq is NotImplemented: return dict(zip(self._parameter.names, self)) == other return eq From 4734a871d9a17a783ca67ae4cf4bbc5f6301d36b Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sun, 12 Mar 2023 18:57:46 +0100 Subject: [PATCH 09/12] Update examples/user_guide/Parameter_Types.ipynb Co-authored-by: James A. Bednar --- examples/user_guide/Parameter_Types.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/user_guide/Parameter_Types.ipynb b/examples/user_guide/Parameter_Types.ipynb index 99a19812c..865b2bc31 100644 --- a/examples/user_guide/Parameter_Types.ipynb +++ b/examples/user_guide/Parameter_Types.ipynb @@ -737,7 +737,7 @@ "\n", "Otherwise, the objects should be provided as a _name_:_value_ dictionary, where the string name will be stored for use in such a UI, but is not otherwise accessed by Param. The values from setting and getting the parameter are always the actual underlying object, not the string names. Because the string name will need to be looked up from the value if this parameter is used in a UI, all objects need to be hashable via the `param.hashable()` function, which accepts Python literals plus list and dictionary types (treating them like tuples).\n", "\n", - "To make it easier to modify `objects` they are wrapped in a object that supports both list and dictionary style methods. This ensures that there is a consistent API for updating the `objects` and that modifying the objects in-place still triggers an event.\n", + "To make it easier to modify the collection of `objects`, they are wrapped in a container that supports both list-style and dictionary-style methods. This approach ensures that there is a consistent API for updating the `objects` and that modifying the objects in place still triggers an event.\n", "\n", "If the list of available objects is not meant be exhaustive, you can specify `check_on_set=False` (which automatically applies if the initial list is empty). Objects will then be added to the `objects` list whenever they are set, including as the initial default. `check_on_set=False` can be useful when the predefined set of objects is not exhaustive, letting a user select from the existing list for convenience while also being able to supply any other suitable object they can construct. When `check_on_set=True`, the initial value (and all subsequent values) must be in the `objects` list.\n", "\n", From b4b5ffcf8fbd1614d4194f5b25000a5148f32efb Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Sun, 12 Mar 2023 18:57:52 +0100 Subject: [PATCH 10/12] Update param/__init__.py Co-authored-by: James A. Bednar --- param/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/param/__init__.py b/param/__init__.py index b200bfa91..71d445a75 100644 --- a/param/__init__.py +++ b/param/__init__.py @@ -1206,9 +1206,7 @@ def get_range(self): class SelectObjects(list): """ - Wrapper around Selector.objects which allows both list-like and - dictionary like updates to the set of objects, ensuring backward - compatibility for setting objects as lists and dictionaries. + Container that supports both list-style and dictionary-style updates. Useful for replacing code that originally accepted lists but needs to support dictionary access (typically for naming items). """ def __init__(self, iterable, parameter=None): From 62ffdd6d41d6aab6bfd67044fd046acb850dc547 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 5 Apr 2023 12:47:32 +0200 Subject: [PATCH 11/12] Rename to ListProxy --- param/__init__.py | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/param/__init__.py b/param/__init__.py index 71d445a75..9127c87fc 100644 --- a/param/__init__.py +++ b/param/__init__.py @@ -1204,13 +1204,16 @@ def get_range(self): -class SelectObjects(list): +class ListProxy(list): """ - Container that supports both list-style and dictionary-style updates. Useful for replacing code that originally accepted lists but needs to support dictionary access (typically for naming items). + Container that supports both list-style and dictionary-style + updates. Useful for replacing code that originally accepted lists + but needs to support dictionary access (typically for naming + items). """ def __init__(self, iterable, parameter=None): - super(SelectObjects, self).__init__(iterable) + super(ListProxy, self).__init__(iterable) self._parameter = parameter def _warn(self, method): @@ -1233,14 +1236,14 @@ def _trigger(self, trigger=True): def __getitem__(self, index): if self._parameter.names: return self._parameter.names[index] - return super(SelectObjects, self).__getitem__(index) + return super(ListProxy, self).__getitem__(index) def __setitem__(self, index, object, trigger=True): if isinstance(index, (int, slice)): if self._parameter.names: self._warn('[index] = object') with self._trigger(): - super(SelectObjects, self).__setitem__(index, object) + super(ListProxy, self).__setitem__(index, object) self._parameter._objects[index] = object return if self and not self._parameter.names: @@ -1249,15 +1252,15 @@ def __setitem__(self, index, object, trigger=True): if index in self._parameter.names: old = self._parameter.names[index] idx = self.index(old) - super(SelectObjects, self).__setitem__(idx, object) + super(ListProxy, self).__setitem__(idx, object) self._parameter._objects[idx] = object else: - super(SelectObjects, self).append(object) + super(ListProxy, self).append(object) self._parameter._objects.append(object) self._parameter.names[index] = object def __eq__(self, other): - eq = super(SelectObjects, self).__eq__(other) + eq = super(ListProxy, self).__eq__(other) if self._parameter.names and eq is NotImplemented: return dict(zip(self._parameter.names, self)) == other return eq @@ -1269,7 +1272,7 @@ def append(self, object): if self._parameter.names: self._warn('.append') with self._trigger(): - super(SelectObjects, self).append(object) + super(ListProxy, self).append(object) self._parameter._objects.append(object) def copy(self): @@ -1279,7 +1282,7 @@ def copy(self): def clear(self): with self._trigger(): - super(SelectObjects, self).clear() + super(ListProxy, self).clear() self._parameter._objects.clear() self._parameter.names.clear() @@ -1287,7 +1290,7 @@ def extend(self, objects): if self._parameter.names: self._warn('.append') with self._trigger(): - super(SelectObjects, self).extend(objects) + super(ListProxy, self).extend(objects) self._parameter._objects.extend(objects) def get(self, key, default=None): @@ -1299,7 +1302,7 @@ def insert(self, index, object): if self._parameter.names: self._warn('.insert') with self._trigger(): - super(SelectObjects, self).insert(index, object) + super(ListProxy, self).insert(index, object) self._parameter._objects.insert(index, object) def items(self): @@ -1316,7 +1319,7 @@ def pop(self, *args): index = args[0] if args else -1 if isinstance(index, int): with self._trigger(): - super(SelectObjects, self).pop(index) + super(ListProxy, self).pop(index) object = self._parameter._objects.pop(index) if self._parameter.names: self._parameter.names = { @@ -1331,13 +1334,13 @@ def pop(self, *args): ) with self._trigger(): object = self._parameter.names.pop(*args) - super(SelectObjects, self).remove(object) + super(ListProxy, self).remove(object) self._parameter._objects.remove(object) return object def remove(self, object): with self._trigger(): - super(SelectObjects, self).remove(object) + super(ListProxy, self).remove(object) self._parameter._objects.remove(object) if self._parameter.names: copy = self._parameter.names.copy() @@ -1444,7 +1447,7 @@ def __init__(self, objects=None, default=None, instantiate=False, @property def objects(self): - return SelectObjects(self._objects, self) + return ListProxy(self._objects, self) @objects.setter def objects(self, objects): From 8d06ffc923d989268a753954bd10a3dfaf3203ca Mon Sep 17 00:00:00 2001 From: maximlt Date: Wed, 12 Apr 2023 11:41:16 +0200 Subject: [PATCH 12/12] default value of names is an empty dict --- tests/API1/testfileselector.py | 2 +- tests/API1/testlistselector.py | 2 +- tests/API1/testmultifileselector.py | 2 +- tests/API1/testobjectselector.py | 2 +- tests/API1/testselector.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/API1/testfileselector.py b/tests/API1/testfileselector.py index 792911edd..d934fdd8c 100644 --- a/tests/API1/testfileselector.py +++ b/tests/API1/testfileselector.py @@ -51,7 +51,7 @@ def _check_defaults(self, p): assert p.objects == [] assert p.compute_default_fn is None assert p.check_on_set is False - assert p.names is None + assert p.names == {} assert p.path == "" def test_defaults_class(self): diff --git a/tests/API1/testlistselector.py b/tests/API1/testlistselector.py index cb382ed6e..90923f477 100644 --- a/tests/API1/testlistselector.py +++ b/tests/API1/testlistselector.py @@ -27,7 +27,7 @@ def _check_defaults(self, p): assert p.objects == [] assert p.compute_default_fn is None assert p.check_on_set is False - assert p.names is None + assert p.names == {} def test_defaults_class(self): class P(param.Parameterized): diff --git a/tests/API1/testmultifileselector.py b/tests/API1/testmultifileselector.py index 59e482db3..6d3cfc276 100644 --- a/tests/API1/testmultifileselector.py +++ b/tests/API1/testmultifileselector.py @@ -51,7 +51,7 @@ def _check_defaults(self, p): assert p.objects == [] assert p.compute_default_fn is None assert p.check_on_set is False - assert p.names is None + assert p.names == {} assert p.path == '' def test_defaults_class(self): diff --git a/tests/API1/testobjectselector.py b/tests/API1/testobjectselector.py index a947a4906..e697ed708 100644 --- a/tests/API1/testobjectselector.py +++ b/tests/API1/testobjectselector.py @@ -45,7 +45,7 @@ def _check_defaults(self, p): assert p.objects == [] assert p.compute_default_fn is None assert p.check_on_set is False - assert p.names is None + assert p.names == {} def test_defaults_class(self): class P(param.Parameterized): diff --git a/tests/API1/testselector.py b/tests/API1/testselector.py index 8b7f2e46c..93cc4e4ca 100644 --- a/tests/API1/testselector.py +++ b/tests/API1/testselector.py @@ -36,7 +36,7 @@ def _check_defaults(self, p): assert p.objects == [] assert p.compute_default_fn is None assert p.check_on_set is False - assert p.names is None + assert p.names == {} def test_defaults_class(self): class P(param.Parameterized):