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

Adds support for required/ mandatory parameter values #724

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/user_guide/Parameters.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
]
Expand Down
41 changes: 38 additions & 3 deletions param/parameterized.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand Down Expand Up @@ -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).
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down
93 changes: 93 additions & 0 deletions tests/testparameterizedobject.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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