Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SelectorObjects wrapper for forward and backward compatibility #598

Merged
merged 14 commits into from
Apr 12, 2023
4 changes: 3 additions & 1 deletion examples/user_guide/Parameter_Types.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -715,7 +715,7 @@
"outputs": [],
"source": [
"with param.exceptions_summarized():\n",
" p.d='Parameter_Types.ipynb'"
" p.d = 'Parameter_Types.ipynb'"
]
},
{
Expand All @@ -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 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",
"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",
Expand Down
212 changes: 197 additions & 15 deletions param/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ( Undefined,
Parameterized, Parameter, String, ParameterizedFunction, ParamOverrides,
descendents, get_logger, instance_descriptor, basestring, dt_types,
Expand All @@ -36,8 +40,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
Expand Down Expand Up @@ -1221,6 +1223,178 @@ def get_range(self):
raise NotImplementedError("get_range() must be implemented in subclasses.")


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).
"""

def __init__(self, iterable, parameter=None):
super(ListProxy, 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):
trigger = 'objects' in self._parameter.watchers and trigger
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 __getitem__(self, index):
if self._parameter.names:
return self._parameter.names[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(ListProxy, self).__setitem__(index, object)
self._parameter._objects[index] = object
return
if self and not self._parameter.names:
self._parameter.names = named_objs(self)
with self._trigger(trigger):
if index in self._parameter.names:
old = self._parameter.names[index]
idx = self.index(old)
super(ListProxy, self).__setitem__(idx, object)
self._parameter._objects[idx] = object
else:
super(ListProxy, self).append(object)
self._parameter._objects.append(object)
self._parameter.names[index] = object

def __eq__(self, 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

def __ne__(self, other):
return not self.__eq__(other)

def append(self, object):
if self._parameter.names:
self._warn('.append')
with self._trigger():
super(ListProxy, 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(ListProxy, 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(ListProxy, 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 named_objs(self).get(key, default)

def insert(self, index, object):
if self._parameter.names:
self._warn('.insert')
with self._trigger():
super(ListProxy, self).insert(index, object)
self._parameter._objects.insert(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
if isinstance(index, int):
with self._trigger():
super(ListProxy, 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(ListProxy, self).remove(object)
self._parameter._objects.remove(object)
return object

def remove(self, object):
with self._trigger():
super(ListProxy, self).remove(object)
self._parameter._objects.remove(object)
if self._parameter.names:
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 update(self, objects, **items):
if not self._parameter.names:
self._parameter.names = named_objs(self)
objects = objects.items() if isinstance(objects, dict) else objects
with self._trigger():
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)

def values(self):
if self._parameter.names:
return self._parameter.names.values()
return named_objs(self).values()
philippjfr marked this conversation as resolved.
Show resolved Hide resolved


def _compute_selector_default(p):
"""
Using a function instead of setting default to [] in _slot_defaults, as
Expand All @@ -1234,6 +1408,7 @@ def _compute_selector_default(p):
def _compute_selector_checking_default(p):
return len(p.objects) != 0


class Selector(SelectorBase):
"""
Parameter whose value must be one object from a list of possible objects.
Expand Down Expand Up @@ -1263,10 +1438,10 @@ class Selector(SelectorBase):
empty_default is an internal argument that does not have a slot.
"""

__slots__ = ['objects', 'compute_default_fn', 'check_on_set', 'names']
__slots__ = ['_objects', 'compute_default_fn', 'check_on_set', 'names']

_slot_defaults = _dict_update(
SelectorBase._slot_defaults, objects=_compute_selector_default,
SelectorBase._slot_defaults, _objects=_compute_selector_default,
compute_default_fn=None, check_on_set=_compute_selector_checking_default,
allow_None=None, instantiate=False, default=None,
)
Expand All @@ -1292,12 +1467,7 @@ def __init__(self, objects=Undefined, default=Undefined, instantiate=Undefined,

default = autodefault if (not empty_default and default is Undefined) else default

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
self.check_on_set = check_on_set

Expand All @@ -1309,6 +1479,19 @@ def __init__(self, objects=Undefined, default=Undefined, instantiate=Undefined,
if self.default is not None and self.check_on_set is True:
self._validate(self.default)

@property
def objects(self):
return ListProxy(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 = {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default value of names is now an empty dict when objects is a list, instead of None. The test suite fails now, since I added a bunch of tests that check the default values.

Seems like this change was intentional, @philippjfr can you confirm? If so, I can update the tests accordingly.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assumed this was an intentional change and updated the tests accordingly. Can always amend before 2.0 is release if need be.

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.

Expand Down Expand Up @@ -1356,22 +1539,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):
Expand Down Expand Up @@ -2359,7 +2542,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.
Expand Down
4 changes: 3 additions & 1 deletion param/parameterized.py
Original file line number Diff line number Diff line change
Expand Up @@ -1177,9 +1177,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:
Expand Down
2 changes: 1 addition & 1 deletion tests/API1/testfileselector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion tests/API1/testlistselector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion tests/API1/testmultifileselector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading