Skip to content

Commit

Permalink
Add is_instance attribute to the List Parameter (#1023)
Browse files Browse the repository at this point in the history
  • Loading branch information
maximlt authored Feb 21, 2025
1 parent b094224 commit 058f77a
Show file tree
Hide file tree
Showing 3 changed files with 46 additions and 14 deletions.
2 changes: 1 addition & 1 deletion doc/user_guide/Parameter_Types.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,7 @@
"- `param.List`: A Python list of objects, usually of a specified type.\n",
"- `param.HookList`: A list of callable objects, for executing user-defined code at some processing stage\n",
"\n",
"List Parameters accept a Python list of objects. Typically the `item_type` will be specified for those objects, so that the rest of the code does not have to further check types when it refers to those values. Where appropriate, the `bounds` of the list can be set as (_min_length_, _max_length_), defaulting to `(0,None)`. Because List parameters already have an empty value ([]), they do not support `allow_None`.\n",
"List Parameters accept a Python list of objects. Typically the `item_type` will be specified for those objects, so that the rest of the code does not have to further check types when it refers to those values. Where appropriate, the `bounds` of the list can be set as (_min_length_, _max_length_), defaulting to `(0,None)`. Because List parameters already have an empty value ([]), they do not support `allow_None`. `is_instance` defaults to `True` and can be set to `False` to declare the objects must be subclasses of `item_type`.\n",
"\n",
"A `param.HookList` is a list whose elements are callable objects (typically either functions or objects with a `__call__` method). A `HookList` is intended for providing user configurability at various stages of some processing algorithm or pipeline. At present, there is no validation that the provided callable takes any particular number or type of arguments."
]
Expand Down
34 changes: 21 additions & 13 deletions param/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2459,33 +2459,34 @@ class List(Parameter):
The bounds allow a minimum and/or maximum length of
list to be enforced. If the item_type is non-None, all
items in the list are checked to be of that type.
items in the list are checked to be instances of that type if
is_instance is True (default) or subclasses of that type when False.
`class_` is accepted as an alias for `item_type`, but is
deprecated due to conflict with how the `class_` slot is
used in Selector classes.
"""

__slots__ = ['bounds', 'item_type', 'class_']
__slots__ = ['bounds', 'item_type', 'class_', 'is_instance']

_slot_defaults = dict(
Parameter._slot_defaults, class_=None, item_type=None, bounds=(0, None),
instantiate=True, default=[],
instantiate=True, default=[], is_instance=True,
)

@typing.overload
def __init__(
self,
default=[], *, class_=None, item_type=None, instantiate=True, bounds=(0, None),
allow_None=False, doc=None, label=None, precedence=None,
is_instance=True, allow_None=False, doc=None, label=None, precedence=None,
constant=False, readonly=False, pickle_default_value=True, per_instance=True,
allow_refs=False, nested_refs=False
):
...

@_deprecate_positional_args
def __init__(self, default=Undefined, *, class_=Undefined, item_type=Undefined,
instantiate=Undefined, bounds=Undefined, **params):
instantiate=Undefined, bounds=Undefined, is_instance=Undefined, **params):
if class_ is not Undefined:
# PARAM3_DEPRECATION
warnings.warn(
Expand All @@ -2499,6 +2500,7 @@ def __init__(self, default=Undefined, *, class_=Undefined, item_type=Undefined,
self.item_type = class_
else:
self.item_type = item_type
self.is_instance = is_instance
self.class_ = self.item_type
self.bounds = bounds
Parameter.__init__(self, default=default, instantiate=instantiate,
Expand All @@ -2512,7 +2514,7 @@ def _validate(self, val):
"""
self._validate_value(val, self.allow_None)
self._validate_bounds(val, self.bounds)
self._validate_item_type(val, self.item_type)
self._validate_item_type(val, self.item_type, self.is_instance)

def _validate_bounds(self, val, bounds):
"""Check that the list is of the right length and has the right contents."""
Expand Down Expand Up @@ -2548,16 +2550,22 @@ def _validate_value(self, val, allow_None):
f"object of {type(val)}."
)

def _validate_item_type(self, val, item_type):
def _validate_item_type(self, val, item_type, is_instance):
if item_type is None or (self.allow_None and val is None):
return
err_kind = None
for v in val:
if isinstance(v, item_type):
continue
raise TypeError(
f"{_validate_error_prefix(self)} items must be instances "
f"of {item_type!r}, not {type(v)}."
)
if is_instance and not isinstance(v, item_type):
err_kind = "instances"
obj_display = lambda v: type(v)
elif not is_instance and (type(v) is not type or not issubclass(v, item_type)):
err_kind = "subclasses"
obj_display = lambda v: v
if err_kind:
raise TypeError(
f"{_validate_error_prefix(self)} items must be {err_kind} "
f"of {item_type!r}, not {obj_display(v)}."
)


class HookList(List):
Expand Down
24 changes: 24 additions & 0 deletions tests/testlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,22 @@
# TODO: tests copied from testobjectselector could use assertRaises
# context manager (and could be updated in testobjectselector too).

class Obj: pass
class SubObj1(Obj): pass
class SubObj2(Obj): pass
class DiffObj: pass

class TestListParameters(unittest.TestCase):

def setUp(self):
super().setUp()

class P(param.Parameterized):
e = param.List([5,6,7], item_type=int)
l = param.List(["red","green","blue"], item_type=str, bounds=(0,10))
m = param.List([1, 2, 3], bounds=(3, None))
n = param.List([1], bounds=(None, 3))
o = param.List([SubObj1, SubObj2], item_type=Obj, is_instance=False)

self.P = P

Expand All @@ -31,6 +38,7 @@ def _check_defaults(self, p):
assert p.item_type is None
assert p.bounds == (0, None)
assert p.instantiate is True
assert p.is_instance is True

def test_defaults_class(self):
class P(param.Parameterized):
Expand Down Expand Up @@ -86,6 +94,22 @@ def test_set_object_outside_upper_bounds(self):
):
p.n = [6] * 4

def test_set_object_wrong_is_instance(self):
p = self.P()
with pytest.raises(
TypeError,
match=re.escape("List parameter 'P.o' items must be subclasses of <class 'tests.testlist.Obj'>, not")
):
p.o = [SubObj1()]

def test_set_object_wrong_is_instance_type(self):
p = self.P()
with pytest.raises(
TypeError,
match=re.escape("List parameter 'P.o' items must be subclasses of <class 'tests.testlist.Obj'>, not")
):
p.o = [DiffObj]

def test_set_object_wrong_type(self):
p = self.P()
with pytest.raises(
Expand Down

0 comments on commit 058f77a

Please sign in to comment.