summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--ipalib/parameter.py147
-rw-r--r--tests/test_ipalib/test_parameter.py72
2 files changed, 209 insertions, 10 deletions
diff --git a/ipalib/parameter.py b/ipalib/parameter.py
index d67eb5957..b071e9d57 100644
--- a/ipalib/parameter.py
+++ b/ipalib/parameter.py
@@ -72,6 +72,110 @@ def parse_param_spec(spec):
return (spec, dict(required=True, multivalue=False))
+class DefaultFrom(ReadOnly):
+ """
+ Derive a default value from other supplied values.
+
+ For example, say you wanted to create a default for the user's login from
+ the user's first and last names. It could be implemented like this:
+
+ >>> login = DefaultFrom(lambda first, last: first[0] + last)
+ >>> login(first='John', last='Doe')
+ 'JDoe'
+
+ If you do not explicitly provide keys when you create a DefaultFrom
+ instance, the keys are implicitly derived from your callback by
+ inspecting ``callback.func_code.co_varnames``. The keys are available
+ through the ``DefaultFrom.keys`` instance attribute, like this:
+
+ >>> login.keys
+ ('first', 'last')
+
+ The callback is available through the ``DefaultFrom.callback`` instance
+ attribute, like this:
+
+ >>> login.callback # doctest:+ELLIPSIS
+ <function <lambda> at 0x...>
+ >>> login.callback.func_code.co_varnames # The keys
+ ('first', 'last')
+
+ The keys can be explicitly provided as optional positional arguments after
+ the callback. For example, this is equivalent to the ``login`` instance
+ above:
+
+ >>> login2 = DefaultFrom(lambda a, b: a[0] + b, 'first', 'last')
+ >>> login2.keys
+ ('first', 'last')
+ >>> login2.callback.func_code.co_varnames # Not the keys
+ ('a', 'b')
+ >>> login2(first='John', last='Doe')
+ 'JDoe'
+
+ If any keys are missing when calling your DefaultFrom instance, your
+ callback is not called and None is returned. For example:
+
+ >>> login(first='John', lastname='Doe') is None
+ True
+ >>> login() is None
+ True
+
+ Any additional keys are simply ignored, like this:
+
+ >>> login(last='Doe', first='John', middle='Whatever')
+ 'JDoe'
+
+ As above, because `DefaultFrom.__call__` takes only pure keyword
+ arguments, they can be supplied in any order.
+
+ Of course, the callback need not be a lambda expression. This third
+ example is equivalent to both the ``login`` and ``login2`` instances
+ above:
+
+ >>> def get_login(first, last):
+ ... return first[0] + last
+ ...
+ >>> login3 = DefaultFrom(get_login)
+ >>> login3.keys
+ ('first', 'last')
+ >>> login3.callback.func_code.co_varnames
+ ('first', 'last')
+ >>> login3(first='John', last='Doe')
+ 'JDoe'
+ """
+
+ def __init__(self, callback, *keys):
+ """
+ :param callback: The callable to call when all keys are present.
+ :param keys: Optional keys used for source values.
+ """
+ if not callable(callback):
+ raise TypeError('callback must be callable; got %r' % callback)
+ self.callback = callback
+ if len(keys) == 0:
+ fc = callback.func_code
+ self.keys = fc.co_varnames[:fc.co_argcount]
+ else:
+ self.keys = keys
+ for key in self.keys:
+ if type(key) is not str:
+ raise_TypeError(key, str, 'keys')
+ lock(self)
+
+ def __call__(self, **kw):
+ """
+ If all keys are present, calls the callback; otherwise returns None.
+
+ :param kw: The keyword arguments.
+ """
+ vals = tuple(kw.get(k, None) for k in self.keys)
+ if None in vals:
+ return
+ try:
+ return self.callback(*vals)
+ except StandardError:
+ pass
+
+
class Param(ReadOnly):
"""
Base class for all IPA types.
@@ -89,14 +193,23 @@ class Param(ReadOnly):
flags=(frozenset, frozenset()),
)
- def __init__(self, name, kwargs, **overrides):
+ def __init__(self, name, kwargs, **override):
self.param_spec = name
+ self.__override = dict(override)
+ if not ('required' in override or 'multivalue' in override):
+ (name, kw_from_spec) = parse_param_spec(name)
+ override.update(kw_from_spec)
self.name = check_name(name)
+ if 'cli_name' not in override:
+ override['cli_name'] = self.name
+ df = override.get('default_from', None)
+ if callable(df) and not isinstance(df, DefaultFrom):
+ override['default_from'] = DefaultFrom(df)
kwargs = dict(kwargs)
assert set(self.__kwargs).intersection(kwargs) == set()
kwargs.update(self.__kwargs)
for (key, (kind, default)) in kwargs.iteritems():
- value = overrides.get(key, default)
+ value = override.get(key, default)
if value is None:
if kind is bool:
raise TypeError(
@@ -104,7 +217,8 @@ class Param(ReadOnly):
)
else:
if (
- type(kind) is type and type(value) is not kind or
+ type(kind) is type and type(value) is not kind
+ or
type(kind) is tuple and not isinstance(value, kind)
):
raise TypeError(
@@ -119,12 +233,35 @@ class Param(ReadOnly):
key, self.__class__.__name__)
)
setattr(self, key, value)
+ check_name(self.cli_name)
lock(self)
def normalize(self, value):
"""
+ Normalize ``value`` using normalizer callback.
+
+ For example:
+
+ >>> param = Str('telephone',
+ ... normalizer=lambda value: value.replace('.', '-')
+ ... )
+ >>> param.normalize(u'800.123.4567')
+ u'800-123-4567'
+
+ (Note that `Str` is a subclass of `Param`.)
+
+ If this `Param` instance was created with a normalizer callback and
+ ``value`` is a unicode instance, the normalizer callback is called and
+ *its* return value is returned.
+
+ On the other hand, if this `Param` instance was *not* created with a
+ normalizer callback, if ``value`` is *not* a unicode instance, or if an
+ exception is caught when calling the normalizer callback, ``value`` is
+ returned unchanged.
+
+ :param value: A proposed value for this parameter.
"""
- if self.__normalize is None:
+ if self.normalizer is None:
return value
if self.multivalue:
if type(value) in (tuple, list):
@@ -143,7 +280,7 @@ class Param(ReadOnly):
if type(value) is not unicode:
return value
try:
- return self.__normalize(value)
+ return self.normalizer(value)
except StandardError:
return value
diff --git a/tests/test_ipalib/test_parameter.py b/tests/test_ipalib/test_parameter.py
index 725ce60ac..84e9fc0e9 100644
--- a/tests/test_ipalib/test_parameter.py
+++ b/tests/test_ipalib/test_parameter.py
@@ -22,12 +22,67 @@
Test the `ipalib.parameter` module.
"""
-from tests.util import raises, ClassChecker
+from tests.util import raises, ClassChecker, read_only
from tests.data import binary_bytes, utf8_bytes, unicode_str
from ipalib import parameter
from ipalib.constants import TYPE_ERROR, CALLABLE_ERROR
+class test_DefaultFrom(ClassChecker):
+ """
+ Test the `ipalib.parameter.DefaultFrom` class.
+ """
+ _cls = parameter.DefaultFrom
+
+ def test_init(self):
+ """
+ Test the `ipalib.parameter.DefaultFrom.__init__` method.
+ """
+ def callback(*args):
+ return args
+ keys = ('givenname', 'sn')
+ o = self.cls(callback, *keys)
+ assert read_only(o, 'callback') is callback
+ assert read_only(o, 'keys') == keys
+ lam = lambda first, last: first[0] + last
+ o = self.cls(lam)
+ assert read_only(o, 'keys') == ('first', 'last')
+
+ def test_call(self):
+ """
+ Test the `ipalib.parameter.DefaultFrom.__call__` method.
+ """
+ def callback(givenname, sn):
+ return givenname[0] + sn[0]
+ keys = ('givenname', 'sn')
+ o = self.cls(callback, *keys)
+ kw = dict(
+ givenname='John',
+ sn='Public',
+ hello='world',
+ )
+ assert o(**kw) == 'JP'
+ assert o() is None
+ for key in ('givenname', 'sn'):
+ kw_copy = dict(kw)
+ del kw_copy[key]
+ assert o(**kw_copy) is None
+
+ # Test using implied keys:
+ o = self.cls(lambda first, last: first[0] + last)
+ assert o(first='john', last='doe') == 'jdoe'
+ assert o(first='', last='doe') is None
+ assert o(one='john', two='doe') is None
+
+ # Test that co_varnames slice is used:
+ def callback2(first, last):
+ letter = first[0]
+ return letter + last
+ o = self.cls(callback2)
+ assert o.keys == ('first', 'last')
+ assert o(first='john', last='doe') == 'jdoe'
+
+
def test_parse_param_spec():
"""
Test the `ipalib.parameter.parse_param_spec` function.
@@ -51,8 +106,11 @@ class test_Param(ClassChecker):
"""
name = 'my_param'
o = self.cls(name, {})
+ assert o.__islocked__() is True
+
+ # Test default values:
assert o.name is name
- # assert o.cli_name is name
+ assert o.cli_name is name
assert o.doc == ''
assert o.required is True
assert o.multivalue is False
@@ -61,7 +119,9 @@ class test_Param(ClassChecker):
assert o.default is None
assert o.default_from is None
assert o.flags == frozenset()
- assert o.__islocked__() is True
+
+ # Test that ValueError is raised when a kwarg from a subclass
+ # conflicts with an attribute:
kwarg = dict(convert=(callable, None))
e = raises(ValueError, self.cls, name, kwarg)
assert str(e) == "kwarg 'convert' conflicts with attribute on Param"
@@ -69,13 +129,15 @@ class test_Param(ClassChecker):
pass
e = raises(ValueError, Subclass, name, kwarg)
assert str(e) == "kwarg 'convert' conflicts with attribute on Subclass"
+
+ # Test type validation of keyword arguments:
kwargs = dict(
extra1=(bool, True),
extra2=(str, 'Hello'),
extra3=((int, float), 42),
extra4=(callable, lambda whatever: whatever + 7),
)
- # Check that we don't accept None if kind is bool:
+ # Note: we don't accept None if kind is bool:
e = raises(TypeError, self.cls, 'my_param', kwargs, extra1=None)
assert str(e) == TYPE_ERROR % ('extra1', bool, None, type(None))
for (key, (kind, default)) in kwargs.items():
@@ -88,7 +150,7 @@ class test_Param(ClassChecker):
assert str(e) == CALLABLE_ERROR % (key, value, type(value))
else:
assert str(e) == TYPE_ERROR % (key, kind, value, type(value))
- if kind is bool:
+ if kind is bool: # See note above
continue
# Test with None:
overrides = {key: None}