Skip to content
Merged
314 changes: 205 additions & 109 deletions param/__init__.py

Large diffs are not rendered by default.

53 changes: 50 additions & 3 deletions param/_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import inspect
import functools
import re
import traceback
import warnings

from textwrap import dedent
Expand Down Expand Up @@ -120,7 +121,53 @@ def _is_auto_name(class_name, instance_name):
return re.match('^'+class_name+'[0-9]{5}$', instance_name)


def _validate_error_prefix(parameter):
def _find_pname(pclass):
"""
Go up the stack and attempt to find a Parameter declaration of the form
`pname = param.Parameter(` or `pname = pm.Parameter(`.
"""
stack = traceback.extract_stack()
for frame in stack:
match = re.match(r"^(\S+)\s*=\s*(param|pm)\." + pclass + r"\(", frame.line)
if match:
return match.group(1)


def _validate_error_prefix(parameter, attribute=None):
"""
Generate an error prefix suitable for Parameters when they raise a validation
error.

- unbound and name can't be found: "Number parameter"
- unbound and name can be found: "Number parameter 'x'"
- bound parameter: "Number parameter 'P.x'"
"""
from param.parameterized import ParameterizedMetaclass

pclass = type(parameter).__name__
pname = '' if parameter.name is None else f' {parameter.name!r}'
return f'{pclass!r} Parameter{pname}'
if parameter.owner is not None:
if type(parameter.owner) is ParameterizedMetaclass:
powner = parameter.owner.__name__
else:
powner = type(parameter.owner).__name__
else:
powner = None
pname = parameter.name
out = []
if attribute:
out.append(f'Attribute {attribute!r} of')
out.append(f'{pclass} parameter')
if pname:
if powner:
desc = f'{powner}.{pname}'
else:
desc = pname
out.append(f'{desc!r}')
else:
try:
pname = _find_pname(pclass)
if pname:
out.append(f'{pname!r}')
except Exception:
pass
return ' '.join(out)
12 changes: 9 additions & 3 deletions param/parameterized.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
_deprecate_positional_args,
_is_auto_name,
_recursive_repr,
_validate_error_prefix,
ParamDeprecationWarning as _ParamDeprecationWarning,
)

Expand Down Expand Up @@ -1544,14 +1545,19 @@ def _validate_regex(self, val, regex):
if (val is None and self.allow_None):
return
if regex is not None and re.match(regex, val) is None:
raise ValueError(f"String parameter {self.name!r} value {val!r} does not match regex {regex!r}.")
raise ValueError(
f'{_validate_error_prefix(self)} value {val!r} does not '
f'match regex {regex!r}.'
)

def _validate_value(self, val, allow_None):
if allow_None and val is None:
return
if not isinstance(val, str):
raise ValueError("String parameter {!r} only takes a string value, "
"not value of type {}.".format(self.name, type(val)))
raise ValueError(
f'{_validate_error_prefix(self)} only takes a string value, '
f'not value of {type(val)}.'
)

def _validate(self, val):
self._validate_value(val, self.allow_None)
Expand Down
4 changes: 2 additions & 2 deletions tests/testaddparameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ class P(param.Parameterized):

P.param.add_parameter('y', param.Number())

with pytest.raises(ValueError, match=r"Parameter 'y' only takes numeric values, not type <class 'str'>."):
with pytest.raises(ValueError, match=r"Number parameter 'P.y' only takes numeric values, not <class 'str'>."):
P.y = 'test'


Expand All @@ -74,7 +74,7 @@ class P(param.Parameterized):

p = P()

with pytest.raises(ValueError, match=r"Parameter 'y' only takes numeric values, not type <class 'str'>."):
with pytest.raises(ValueError, match=r"Number parameter 'P.y' only takes numeric values, not <class 'str'>."):
p.y = 'test'


Expand Down
10 changes: 5 additions & 5 deletions tests/testbooleanparam.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,15 @@ def test_default_is_None(self):
def test_raise_None_when_not_allowed(self):
p = self.P()

msg = r"Boolean parameter 'e' must be True or False, not None"
msg = r"Boolean parameter 'P.e' must be True or False, not None"
with self.assertRaisesRegex(ValueError, msg):
p.e = None

with self.assertRaisesRegex(ValueError, msg):
self.P.e = None

def test_bad_type(self):
msg = r"Boolean parameter 'e' must be True or False, not test"
msg = r"Boolean parameter 'P.e' must be True or False, not 'test'"

with self.assertRaisesRegex(ValueError, msg):
self.P.e = 'test'
Expand All @@ -78,7 +78,7 @@ def test_bad_type(self):
p.e = 'test'

def test_bad_default_type(self):
msg = r"Boolean parameter must be True or False, not test."
msg = r"Boolean parameter 'b' must be True or False, not 'test'\."

with self.assertRaisesRegex(ValueError, msg):
class A(param.Parameterized):
Expand Down Expand Up @@ -137,15 +137,15 @@ def test_default_is_None(self):
def test_raise_None_when_not_allowed(self):
p = self.P()

msg = r"Boolean parameter 'e' must be True or False, not None"
msg = r"Event parameter 'P.e' must be True or False, not None"
with self.assertRaisesRegex(ValueError, msg):
p.e = None

with self.assertRaisesRegex(ValueError, msg):
self.P.e = None

def test_bad_type(self):
msg = r"Boolean parameter 'e' must be True or False, not test"
msg = r"Event parameter 'P.e' must be True or False, not 'test'"

with self.assertRaisesRegex(ValueError, msg):
self.P.e = 'test'
Expand Down
6 changes: 3 additions & 3 deletions tests/testbytesparam.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ class A(param.Parameterized):

a = A()

exception = "Bytes parameter 's' only takes a byte string value, not value of type <class 'NoneType'>."
exception = "Bytes parameter 'A.s' only takes a byte string value, not value of <class 'NoneType'>."
with self.assertRaisesRegex(ValueError, exception):
a.s = None # because allow_None should be False

Expand All @@ -85,13 +85,13 @@ class A(param.Parameterized):

a = A()

exception = "Bytes parameter 's' value b'123.123.0.256' does not match regex %r." % ip_regex
exception = "Bytes parameter 'A.s' value b'123.123.0.256' does not match regex %r." % ip_regex
with self.assertRaises(ValueError) as e:
a.s = b'123.123.0.256'
self.assertEqual(str(e.exception), exception)

def test_regex_incorrect_default(self):
exception = f"Bytes parameter None value b'' does not match regex {ip_regex!r}."
exception = f"Bytes parameter 's' value b'' does not match regex {ip_regex!r}."
with self.assertRaises(ValueError) as e:
class A(param.Parameterized):
s = param.Bytes(regex=ip_regex) # default value '' does not match regular expression
Expand Down
27 changes: 9 additions & 18 deletions tests/testcalendardateparam.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Unit test for CalendarDate parameters.
"""
import datetime as dt
import re
import unittest

import pytest
Expand Down Expand Up @@ -43,38 +44,26 @@ def test_defaults_unbound(self):
self._check_defaults(d)

def test_initialization_out_of_bounds(self):
try:
with pytest.raises(ValueError):
class Q(param.Parameterized):
q = param.CalendarDate(dt.date(2017,2,27),
bounds=(dt.date(2017,2,1),
dt.date(2017,2,26)))
except ValueError:
pass
else:
raise AssertionError("No exception raised on out-of-bounds date")

def test_set_out_of_bounds(self):
class Q(param.Parameterized):
q = param.CalendarDate(bounds=(dt.date(2017,2,1),
dt.date(2017,2,26)))
try:
with pytest.raises(ValueError):
Q.q = dt.date(2017,2,27)
except ValueError:
pass
else:
raise AssertionError("No exception raised on out-of-bounds date")

def test_set_exclusive_out_of_bounds(self):
class Q(param.Parameterized):
q = param.CalendarDate(bounds=(dt.date(2017,2,1),
dt.date(2017,2,26)),
inclusive_bounds=(True, False))
try:
with pytest.raises(ValueError):
Q.q = dt.date(2017,2,26)
except ValueError:
pass
else:
raise AssertionError("No exception raised on out-of-bounds date")

def test_get_soft_bounds(self):
q = param.CalendarDate(dt.date(2017,2,25),
Expand All @@ -86,10 +75,12 @@ def test_get_soft_bounds(self):
dt.date(2017,2,25)))

def test_datetime_not_accepted(self):
with pytest.raises(ValueError):
with pytest.raises(ValueError, match=re.escape('CalendarDate parameter only takes date types.')):
param.CalendarDate(dt.datetime(2021, 8, 16, 10))

def test_step_invalid_type_parameter(self):
exception = "Step can only be None or a date type"
with self.assertRaisesRegex(ValueError, exception):
with pytest.raises(
ValueError,
match=re.escape("Attribute 'step' of CalendarDate parameter can only be None or a date type, not <class 'float'>.")
):
param.CalendarDate(dt.date(2017,2,27), step=3.2)
56 changes: 26 additions & 30 deletions tests/testcalendardaterangeparam.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
Unit tests for CalendarDateRange parameter.
"""
import datetime as dt
import re
import unittest

import param
import pytest

# Assuming tests of range parameter cover most of what's needed to
# test date range.
Expand Down Expand Up @@ -42,65 +44,59 @@ def test_defaults_unbound(self):
bad_range = (dt.date(2017,2,27),dt.date(2017,2,26))

def test_wrong_type_default(self):
try:
with pytest.raises(
ValueError,
match=re.escape("CalendarDateRange parameter 'a' only takes date types, not (1.0, 2.0).")
):
class Q(param.Parameterized):
a = param.CalendarDateRange(default=(1.0,2.0))
except ValueError:
pass
else:
raise AssertionError("Bad date type was accepted.")

def test_wrong_type_init(self):
class Q(param.Parameterized):
a = param.CalendarDateRange()

try:
with pytest.raises(
ValueError,
match=re.escape("CalendarDateRange parameter 'Q.a' end date 2017-02-26 is before start date 2017-02-27.")
):
Q(a=self.bad_range)
except ValueError:
pass
else:
raise AssertionError("Bad date type was accepted.")

def test_wrong_type_set(self):
class Q(param.Parameterized):
a = param.CalendarDateRange()
q = Q()

try:
with pytest.raises(
ValueError,
match=re.escape("CalendarDateRange parameter 'Q.a' end date 2017-02-26 is before start date 2017-02-27.")
):
q.a = self.bad_range
except ValueError:
pass
else:
raise AssertionError("Bad date type was accepted.")

def test_start_before_end_default(self):
try:
with pytest.raises(
ValueError,
match=re.escape("CalendarDateRange parameter 'a' end date 2017-02-26 is before start date 2017-02-27.")
):
class Q(param.Parameterized):
a = param.CalendarDateRange(default=self.bad_range)
except ValueError:
pass
else:
raise AssertionError("Bad date range was accepted.")

def test_start_before_end_init(self):
class Q(param.Parameterized):
a = param.CalendarDateRange()

try:
with pytest.raises(
ValueError,
match=re.escape("CalendarDateRange parameter 'Q.a' end date 2017-02-26 is before start date 2017-02-27.")
):
Q(a=self.bad_range)
except ValueError:
pass
else:
raise AssertionError("Bad date range was accepted.")

def test_start_before_end_set(self):
class Q(param.Parameterized):
a = param.CalendarDateRange()

q = Q()
try:
with pytest.raises(
ValueError,
match=re.escape("CalendarDateRange parameter 'Q.a' end date 2017-02-26 is before start date 2017-02-27.")
):
q.a = self.bad_range
except ValueError:
pass
else:
raise AssertionError("Bad date range was accepted.")
10 changes: 10 additions & 0 deletions tests/testcallable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import param
import pytest


def test_callable_validate():
with pytest.raises(
ValueError,
match=r"Callable parameter 'c' only takes a callable object, not objects of <class 'str'>\."
):
c = param.Callable('wrong') # noqa
Loading