diff --git a/examples/user_guide/Parameters.ipynb b/examples/user_guide/Parameters.ipynb index 6bf8030f9..3f819bddf 100644 --- a/examples/user_guide/Parameters.ipynb +++ b/examples/user_guide/Parameters.ipynb @@ -35,6 +35,7 @@ "- **instantiate**: Whether to deepcopy the default value into a Parameterized instance when it is created. False by default for Parameter and most of its subtypes, but some Parameter types commonly used with mutable containers default to `instantiate=True` to avoid interaction between separate Parameterized instances, and users can control this when declaring the Parameter (see below). \n", "- **per_instance**: whether a separate Parameter instance will be created for every Parameterized instance created. Similar to `instantiate`, but applies to the Parameter object rather than to its value.\n", "- **precedence**: Optional numeric value controlling whether this parameter is visible in a listing and if so in what order.\n", + "- **required**: Whether a value must be provided on instantiation of a Parameterized object.\n", "\n", "Most of these settings (apart from **name**) are accepted as keyword arguments to the Parameter's constructor, with `default` also accepted as a positional argument:" ] diff --git a/param/parameterized.py b/param/parameterized.py index 245a98878..5fcc0cd13 100644 --- a/param/parameterized.py +++ b/param/parameterized.py @@ -986,7 +986,7 @@ class Foo(Bar): __slots__ = ['name', '_internal_name', 'default', 'doc', 'precedence', 'instantiate', 'constant', 'readonly', 'pickle_default_value', 'allow_None', 'per_instance', - 'watchers', 'owner', '_label'] + 'watchers', 'owner', '_label', 'required'] # Note: When initially created, a Parameter does not know which # Parameterized class owns it, nor does it know its names @@ -999,14 +999,14 @@ class Foo(Bar): _slot_defaults = dict( default=None, precedence=None, doc=None, _label=None, instantiate=False, constant=False, readonly=False, pickle_default_value=True, allow_None=False, - per_instance=True + per_instance=True, required=False ) def __init__(self, default=Undefined, doc=Undefined, # pylint: disable-msg=R0913 label=Undefined, precedence=Undefined, instantiate=Undefined, constant=Undefined, readonly=Undefined, pickle_default_value=Undefined, allow_None=Undefined, - per_instance=Undefined): + per_instance=Undefined, required=Undefined): """Initialize a new Parameter object and store the supplied attributes: @@ -1074,6 +1074,9 @@ def __init__(self, default=Undefined, doc=Undefined, # pylint: disable-msg=R0913 allowed. If the default value is defined as None, allow_None is set to True automatically. + required: If True a value must be provided on instantiation of a + Parameterized object. + default, doc, and precedence all default to None, which allows inheritance of Parameter slots (attributes) from the owning-class' class hierarchy (see ParameterizedMetaclass). @@ -1093,6 +1096,7 @@ class hierarchy (see ParameterizedMetaclass). self._set_allow_None(allow_None) self.watchers = {} self.per_instance = per_instance + self.required = required @classmethod def serialize(cls, value): @@ -1653,6 +1657,36 @@ def _generate_name(self_): self = self_.param.self self.param._set_name('%s%05d' % (self.__class__.__name__ ,object_count)) + @as_uninitialized + def _check_required(self_, **params): + cls = self_.param.cls + + # Map of all the class parameters + parameters = {} + for class_ in classlist(cls): + for name, val in class_.__dict__.items(): + if isinstance(val, Parameter): + parameters[name] = val + # Find what Parameters are required but were not passed to the + # constructor. + missing = [ + pname + for pname, p in parameters.items() + if p.required and pname not in params + ] + # Format the error + if missing: + missing = [f'{s!r}' for s in missing] + if len(missing) > 1: + kw = ', '.join(missing[:-1]) + kw = kw + f'{"," if len(missing) > 2 else ""} and {missing[-1]}' + else: + kw = missing[0] + message = ( + f"{cls.name}.__init__() missing {len(missing)} required keyword-only " + f"argument{'s' if len(missing) >1 else ''}: {kw}" + ) + raise TypeError(message) @as_uninitialized def _setup_params(self_,**params): @@ -3205,6 +3239,7 @@ def __init__(self, **params): self._dynamic_watchers = defaultdict(list) self.param._generate_name() + self.param._check_required(**params) self.param._setup_params(**params) object_count += 1 diff --git a/tests/testparameterizedobject.py b/tests/testparameterizedobject.py index ed45bc6f7..2794ac754 100644 --- a/tests/testparameterizedobject.py +++ b/tests/testparameterizedobject.py @@ -824,6 +824,99 @@ class B(A): assert B.p == 1 +@pytest.mark.parametrize(["default", "value"], [ + (None,"v"), ("d", "v") +]) +def test_required(default, value): + """Test that you can make a parameter required and it will not raise + an error if provided.""" + class P(param.Parameterized): + value1 = param.Parameter(default=default, required=True) + value2 = param.Parameter(default=default, required=True) + notrequired = param.Parameter(default=default, required=False) + + po = P(value1=value, value2=value) + assert po.value1 == value + assert po.value2 == value + + +@pytest.mark.parametrize("default", [None, "d"]) +def test_required_raises(default): + """Test that you can make a parameter required and it will raise + an error if not provided.""" + class P(param.Parameterized): + value1 = param.Parameter(default=default, required=True) + notrequired = param.Parameter(default=default, required=False) + + with pytest.raises( + TypeError, + match=re.escape(r"P.__init__() missing 1 required keyword-only argument: 'value1'"), + ): + P() + + class Q(param.Parameterized): + value1 = param.Parameter(default=default, required=True) + value2 = param.Parameter(default=default, required=True) + notrequired = param.Parameter(default=default, required=False) + + with pytest.raises( + TypeError, + match=re.escape(r"Q.__init__() missing 2 required keyword-only arguments: 'value1' and 'value2'"), + ): + Q() + + class R(param.Parameterized): + value1 = param.Parameter(default=default, required=True) + value2 = param.Parameter(default=default, required=True) + value3 = param.Parameter(default=default, required=True) + notrequired = param.Parameter(default=default, required=False) + + with pytest.raises( + TypeError, + match=re.escape(r"R.__init__() missing 3 required keyword-only arguments: 'value1', 'value2', and 'value3'"), + ): + R() + + +def test_required_inheritance(): + class A(param.Parameterized): + p = param.Parameter(default=1, required=True, doc='aaa') + + class B(A): + pass + + class C(B): + p = param.Parameter(required=False, doc='bbb') + + with pytest.raises( + TypeError, + match=re.escape(r"A.__init__() missing 1 required keyword-only argument: 'p'"), + ): + A() + + with pytest.raises( + TypeError, + match=re.escape(r"B.__init__() missing 1 required keyword-only argument: 'p'"), + ): + B() + + c = C() + + assert c.p == 1 + + C.param.p.required = True + + with pytest.raises( + TypeError, + match=re.escape(r"C.__init__() missing 1 required keyword-only argument: 'p'"), + ): + C() + + c = C(p=2) + + assert c.p == 2 + + @pytest.fixture def custom_parameter1(): class CustomParameter(param.Parameter): diff --git a/tests/utils.py b/tests/utils.py index 13771a4cb..7c0160e28 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -93,3 +93,5 @@ def check_defaults(parameter, label, skip=[]): assert parameter.per_instance is True if 'label' not in skip: assert parameter.label == label + if 'required' not in skip: + assert parameter.required is False