diff options
-rw-r--r-- | ipalib/parameter.py | 147 | ||||
-rw-r--r-- | tests/test_ipalib/test_parameter.py | 72 |
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} |