From 5d1e5a0aa9f79e179a5538dcf1bcec5426369951 Mon Sep 17 00:00:00 2001 From: Jason Gerard DeRose Date: Wed, 14 Jan 2009 10:25:33 -0700 Subject: New Param: renamed parameter.py to parameters.py --- ipalib/parameters.py | 882 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 882 insertions(+) create mode 100644 ipalib/parameters.py (limited to 'ipalib/parameters.py') diff --git a/ipalib/parameters.py b/ipalib/parameters.py new file mode 100644 index 000000000..bfe601a93 --- /dev/null +++ b/ipalib/parameters.py @@ -0,0 +1,882 @@ +# Authors: +# Jason Gerard DeRose +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Parameter system for command plugins. +""" + +from types import NoneType +from util import make_repr +from request import ugettext +from plugable import ReadOnly, lock, check_name +from errors2 import ConversionError, RequirementError, ValidationError +from constants import NULLS, TYPE_ERROR, CALLABLE_ERROR + + +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 + 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( + CALLABLE_ERROR % ('callback', callback, type(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( + TYPE_ERROR % ('keys', str, key, type(key)) + ) + lock(self) + + def __call__(self, **kw): + """ + Call the callback if all keys are present. + + If all keys are present, the callback is called and its return value is + returned. If any keys are missing, ``None`` is returned. + + :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 + + +def parse_param_spec(spec): + """ + Parse shorthand ``spec`` into to ``(name, kw)``. + + The ``spec`` string determines the parameter name, whether the parameter is + required, and whether the parameter is multivalue according the following + syntax: + + ====== ===== ======== ========== + Spec Name Required Multivalue + ====== ===== ======== ========== + 'var' 'var' True False + 'var?' 'var' False False + 'var*' 'var' False True + 'var+' 'var' True True + ====== ===== ======== ========== + + For example, + + >>> parse_param_spec('login') + ('login', {'required': True, 'multivalue': False}) + >>> parse_param_spec('gecos?') + ('gecos', {'required': False, 'multivalue': False}) + >>> parse_param_spec('telephone_numbers*') + ('telephone_numbers', {'required': False, 'multivalue': True}) + >>> parse_param_spec('group+') + ('group', {'required': True, 'multivalue': True}) + + :param spec: A spec string. + """ + if type(spec) is not str: + raise TypeError( + TYPE_ERROR % ('spec', str, spec, type(spec)) + ) + if len(spec) < 2: + raise ValueError( + 'spec must be at least 2 characters; got %r' % spec + ) + _map = { + '?': dict(required=False, multivalue=False), + '*': dict(required=False, multivalue=True), + '+': dict(required=True, multivalue=True), + } + end = spec[-1] + if end in _map: + return (spec[:-1], _map[end]) + return (spec, dict(required=True, multivalue=False)) + + +__messages = set() + +def _(message): + __messages.add(message) + return message + + +class Param(ReadOnly): + """ + Base class for all parameters. + """ + + # This is a dummy type so that most of the functionality of Param can be + # unit tested directly without always creating a subclass; however, a real + # (direct) subclass must *always* override this class attribute: + type = NoneType # Ouch, this wont be very useful in the real world! + + # Subclasses should override this with something more specific: + type_error = _('incorrect type') + + kwargs = ( + ('cli_name', str, None), + ('label', callable, None), + ('doc', str, ''), + ('required', bool, True), + ('multivalue', bool, False), + ('primary_key', bool, False), + ('normalizer', callable, None), + ('default_from', DefaultFrom, None), + ('create_default', callable, None), + ('autofill', bool, False), + ('flags', frozenset, frozenset()), + + # The 'default' kwarg gets appended in Param.__init__(): + # ('default', self.type, None), + ) + + def __init__(self, name, *rules, **kw): + # We keep these values to use in __repr__(): + self.param_spec = name + self.__kw = dict(kw) + + # Merge in kw from parse_param_spec(): + if not ('required' in kw or 'multivalue' in kw): + (name, kw_from_spec) = parse_param_spec(name) + kw.update(kw_from_spec) + self.name = check_name(name) + self.nice = '%s(%r)' % (self.__class__.__name__, self.param_spec) + + # Add 'default' to self.kwargs and makes sure no unknown kw were given: + assert type(self.type) is type + self.kwargs += (('default', self.type, None),) + if not set(t[0] for t in self.kwargs).issuperset(self.__kw): + extra = set(kw) - set(t[0] for t in self.kwargs) + raise TypeError( + '%s: takes no such kwargs: %s' % (self.nice, + ', '.join(repr(k) for k in sorted(extra)) + ) + ) + + # Merge in default for 'cli_name' if not given: + if kw.get('cli_name', None) is None: + kw['cli_name'] = self.name + + # Wrap 'default_from' in a DefaultFrom if not already: + df = kw.get('default_from', None) + if callable(df) and not isinstance(df, DefaultFrom): + kw['default_from'] = DefaultFrom(df) + + # We keep this copy with merged values also to use when cloning: + self.__clonekw = kw + + # Perform type validation on kw, add in class rules: + class_rules = [] + for (key, kind, default) in self.kwargs: + value = kw.get(key, default) + if value is not None: + if kind is frozenset: + if type(value) in (list, tuple): + value = frozenset(value) + elif type(value) is str: + value = frozenset([value]) + if ( + type(kind) is type and type(value) is not kind + or + type(kind) is tuple and not isinstance(value, kind) + ): + raise TypeError( + TYPE_ERROR % (key, kind, value, type(value)) + ) + elif kind is callable and not callable(value): + raise TypeError( + CALLABLE_ERROR % (key, value, type(value)) + ) + if hasattr(self, key): + raise ValueError('kwarg %r conflicts with attribute on %s' % ( + key, self.__class__.__name__) + ) + setattr(self, key, value) + rule_name = '_rule_%s' % key + if value is not None and hasattr(self, rule_name): + class_rules.append(getattr(self, rule_name)) + check_name(self.cli_name) + + # Check that only default_from or create_default was provided: + assert not hasattr(self, '_get_default'), self.nice + if callable(self.default_from): + if callable(self.create_default): + raise ValueError( + '%s: cannot have both %r and %r' % ( + self.nice, 'default_from', 'create_default') + ) + self._get_default = self.default_from + elif callable(self.create_default): + self._get_default = self.create_default + else: + self._get_default = None + + # Check that all the rules are callable + self.class_rules = tuple(class_rules) + self.rules = rules + self.all_rules = self.class_rules + self.rules + for rule in self.all_rules: + if not callable(rule): + raise TypeError( + '%s: rules must be callable; got %r' % (self.nice, rule) + ) + + # And we're done. + lock(self) + + def __repr__(self): + """ + Return an expresion that could construct this `Param` instance. + """ + return make_repr( + self.__class__.__name__, + self.param_spec, + **self.__kw + ) + + def __call__(self, value, **kw): + """ + One stop shopping. + """ + if value in NULLS: + value = self.get_default(**kw) + else: + value = self.convert(self.normalize(value)) + self.validate(value) + return value + + def clone(self, **overrides): + """ + Return a new `Param` instance similar to this one. + """ + kw = dict(self.__clonekw) + kw.update(overrides) + return self.__class__(self.name, **kw) + + def get_label(self): + """ + Return translated label using `request.ugettext`. + """ + if self.label is None: + return self.cli_name.decode('UTF-8') + return self.label(ugettext) + + def normalize(self, value): + """ + Normalize ``value`` using normalizer callback. + + For example: + + >>> param = Param('telephone', + ... normalizer=lambda value: value.replace('.', '-') + ... ) + >>> param.normalize(u'800.123.4567') + u'800-123-4567' + + 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.normalizer is None: + return value + if self.multivalue: + if type(value) in (tuple, list): + return tuple( + self._normalize_scalar(v) for v in value + ) + return (self._normalize_scalar(value),) # Return a tuple + return self._normalize_scalar(value) + + def _normalize_scalar(self, value): + """ + Normalize a scalar value. + + This method is called once for each value in a multivalue. + """ + if type(value) is not unicode: + return value + try: + return self.normalizer(value) + except StandardError: + return value + + def convert(self, value): + """ + Convert ``value`` to the Python type required by this parameter. + + For example: + + >>> scalar = Str('my_scalar') + >>> scalar.type + + >>> scalar.convert(43.2) + u'43.2' + + (Note that `Str` is a subclass of `Param`.) + + All values in `constants.NULLS` will be converted to ``None``. For + example: + + >>> scalar.convert(u'') is None # An empty string + True + >>> scalar.convert([]) is None # An empty list + True + + Likewise, values in `constants.NULLS` will be filtered out of a + multivalue parameter. For example: + + >>> multi = Str('my_multi', multivalue=True) + >>> multi.convert([1.5, '', 17, None, u'Hello']) + (u'1.5', u'17', u'Hello') + >>> multi.convert([None, u'']) is None # Filters to an empty list + True + + Lastly, multivalue parameters will always return a ``tuple`` (assuming + they don't return ``None`` as in the last example above). For example: + + >>> multi.convert(42) # Called with a scalar value + (u'42',) + >>> multi.convert([0, 1]) # Called with a list value + (u'0', u'1') + + Note that how values are converted (and from what types they will be + converted) completely depends upon how a subclass implements its + `Param._convert_scalar()` method. For example, see + `Str._convert_scalar()`. + + :param value: A proposed value for this parameter. + """ + if value in NULLS: + return + if self.multivalue: + if type(value) not in (tuple, list): + value = (value,) + values = tuple( + self._convert_scalar(v, i) for (i, v) in filter( + lambda iv: iv[1] not in NULLS, enumerate(value) + ) + ) + if len(values) == 0: + return + return values + return self._convert_scalar(value) + + def _convert_scalar(self, value, index=None): + """ + Convert a single scalar value. + """ + if type(value) is self.type: + return value + raise ConversionError(name=self.name, index=index, + error=ugettext(self.type_error), + ) + + def validate(self, value): + """ + Check validity of ``value``. + + :param value: A proposed value for this parameter. + """ + if value is None: + if self.required: + raise RequirementError(name=self.name) + return + if self.multivalue: + if type(value) is not tuple: + raise TypeError( + TYPE_ERROR % ('value', tuple, value, type(value)) + ) + if len(value) < 1: + raise ValueError('value: empty tuple must be converted to None') + for (i, v) in enumerate(value): + self._validate_scalar(v, i) + else: + self._validate_scalar(value) + + def _validate_scalar(self, value, index=None): + if type(value) is not self.type: + if index is None: + name = 'value' + else: + name = 'value[%d]' % index + raise TypeError( + TYPE_ERROR % (name, self.type, value, type(value)) + ) + if index is not None and type(index) is not int: + raise TypeError( + TYPE_ERROR % ('index', int, index, type(index)) + ) + for rule in self.all_rules: + error = rule(ugettext, value) + if error is not None: + raise ValidationError(name=self.name, error=error, index=index) + + def get_default(self, **kw): + """ + Return the static default or construct and return a dynamic default. + + (In these examples, we will use the `Str` and `Bytes` classes, which + both subclass from `Param`.) + + The *default* static default is ``None``. For example: + + >>> s = Str('my_str') + >>> s.default is None + True + >>> s.get_default() is None + True + + However, you can provide your own static default via the ``default`` + keyword argument when you create your `Param` instance. For example: + + >>> s = Str('my_str', default=u'My Static Default') + >>> s.default + u'My Static Default' + >>> s.get_default() + u'My Static Default' + + If you need to generate a dynamic default from other supplied parameter + values, provide a callback via the ``default_from`` keyword argument. + This callback will be automatically wrapped in a `DefaultFrom` instance + if it isn't one already (see the `DefaultFrom` class for all the gory + details). For example: + + >>> login = Str('login', default=u'my-static-login-default', + ... default_from=lambda first, last: (first[0] + last).lower(), + ... ) + >>> isinstance(login.default_from, DefaultFrom) + True + >>> login.default_from.keys + ('first', 'last') + + Then when all the keys needed by the `DefaultFrom` instance are present, + the dynamic default is constructed and returned. For example: + + >>> kw = dict(last=u'Doe', first=u'John') + >>> login.get_default(**kw) + u'jdoe' + + Or if any keys are missing, your *static* default is returned. + For example: + + >>> kw = dict(first=u'John', department=u'Engineering') + >>> login.get_default(**kw) + u'my-static-login-default' + + The second, less common way to construct a dynamic default is to provide + a callback via the ``create_default`` keyword argument. Unlike a + ``default_from`` callback, your ``create_default`` callback will not get + wrapped in any dispatcher. Instead, it will be called directly, which + means your callback must accept arbitrary keyword arguments, although + whether your callback utilises these values is up to your + implementation. For example: + + >>> def make_csr(**kw): + ... print ' make_csr(%r)' % (kw,) # Note output below + ... return 'Certificate Signing Request' + ... + >>> csr = Bytes('csr', create_default=make_csr) + + Your ``create_default`` callback will be called with whatever keyword + arguments are passed to `Param.get_default()`. For example: + + >>> kw = dict(arbitrary='Keyword', arguments='Here') + >>> csr.get_default(**kw) + make_csr({'arguments': 'Here', 'arbitrary': 'Keyword'}) + 'Certificate Signing Request' + + And your ``create_default`` callback is called even if + `Param.get_default()` is called with *zero* keyword arguments. + For example: + + >>> csr.get_default() + make_csr({}) + 'Certificate Signing Request' + + The ``create_default`` callback will most likely be used as a + pre-execute hook to perform some special client-side operation. For + example, the ``csr`` parameter above might make a call to + ``/usr/bin/openssl``. However, often a ``create_default`` callback + could also be implemented as a ``default_from`` callback. When this is + the case, a ``default_from`` callback should be used as they are more + structured and therefore less error-prone. + + The ``default_from`` and ``create_default`` keyword arguments are + mutually exclusive. If you provide both, a ``ValueError`` will be + raised. For example: + + >>> homedir = Str('home', + ... default_from=lambda login: '/home/%s' % login, + ... create_default=lambda **kw: '/lets/use/this', + ... ) + Traceback (most recent call last): + ... + ValueError: Str('home'): cannot have both 'default_from' and 'create_default' + """ + if self._get_default is not None: + default = self._get_default(**kw) + if default is not None: + try: + return self.convert(self.normalize(default)) + except StandardError: + pass + return self.default + + +class Bool(Param): + """ + A parameter for boolean values (stored in the ``bool`` type). + """ + + type = bool + type_error = _('must be True or False') + + +class Flag(Bool): + """ + A boolean parameter that always gets filled in with a default value. + + This `Bool` subclass forces ``autofill=True`` in `Flag.__init__()`. If no + default is provided, it also fills in a default value of ``False``. + Lastly, unlike the `Bool` class, the default must be either ``True`` or + ``False`` and cannot be ``None``. + + For example: + + >>> flag = Flag('my_flag') + >>> (flag.autofill, flag.default) + (True, False) + + To have a default value of ``True``, create your `Flag` intance with + ``default=True``. For example: + + >>> flag = Flag('my_flag', default=True) + >>> (flag.autofill, flag.default) + (True, True) + + Also note that creating a `Flag` instance with ``autofill=False`` will have + no effect. For example: + + >>> flag = Flag('my_flag', autofill=False) + >>> flag.autofill + True + """ + + def __init__(self, name, *rules, **kw): + kw['autofill'] = True + if 'default' not in kw: + kw['default'] = False + if type(kw['default']) is not bool: + default = kw['default'] + raise TypeError( + TYPE_ERROR % ('default', bool, default, type(default)) + ) + super(Flag, self).__init__(name, *rules, **kw) + + +class Int(Param): + """ + A parameter for integer values (stored in the ``int`` type). + """ + + type = int + type_error = _('must be an integer') + + +class Float(Param): + """ + A parameter for floating-point values (stored in the ``float`` type). + """ + + type = float + type_error = _('must be a decimal number') + + +class Bytes(Param): + """ + A parameter for binary data (stored in the ``str`` type). + + This class is named *Bytes* instead of *Str* so it's aligned with the + Python v3 ``(str, unicode) => (bytes, str)`` clean-up. See: + + http://docs.python.org/3.0/whatsnew/3.0.html + """ + + type = str + type_error = _('must be binary data') + + kwargs = Param.kwargs + ( + ('minlength', int, None), + ('maxlength', int, None), + ('length', int, None), + ('pattern', str, None), + + ) + + def __init__(self, name, *rules, **kw): + super(Bytes, self).__init__(name, *rules, **kw) + + if not ( + self.length is None or + (self.minlength is None and self.maxlength is None) + ): + raise ValueError( + '%s: cannot mix length with minlength or maxlength' % self.nice + ) + + if self.minlength is not None and self.minlength < 1: + raise ValueError( + '%s: minlength must be >= 1; got %r' % (self.nice, self.minlength) + ) + + if self.maxlength is not None and self.maxlength < 1: + raise ValueError( + '%s: maxlength must be >= 1; got %r' % (self.nice, self.maxlength) + ) + + if None not in (self.minlength, self.maxlength): + if self.minlength > self.maxlength: + raise ValueError( + '%s: minlength > maxlength (minlength=%r, maxlength=%r)' % ( + self.nice, self.minlength, self.maxlength) + ) + elif self.minlength == self.maxlength: + raise ValueError( + '%s: minlength == maxlength; use length=%d instead' % ( + self.nice, self.minlength) + ) + + def _rule_minlength(self, _, value): + """ + Check minlength constraint. + """ + assert type(value) is str + if len(value) < self.minlength: + return _('must be at least %(minlength)d bytes') % dict( + minlength=self.minlength, + ) + + def _rule_maxlength(self, _, value): + """ + Check maxlength constraint. + """ + assert type(value) is str + if len(value) > self.maxlength: + return _('can be at most %(maxlength)d bytes') % dict( + maxlength=self.maxlength, + ) + + def _rule_length(self, _, value): + """ + Check length constraint. + """ + assert type(value) is str + if len(value) != self.length: + return _('must be exactly %(length)d bytes') % dict( + length=self.length, + ) + + +class Str(Bytes): + """ + A parameter for Unicode text (stored in the ``unicode`` type). + + This class is named *Str* instead of *Unicode* so it's aligned with the + Python v3 ``(str, unicode) => (bytes, str)`` clean-up. See: + + http://docs.python.org/3.0/whatsnew/3.0.html + """ + + type = unicode + type_error = _('must be Unicode text') + + kwargs = Bytes.kwargs[:-1] + ( + ('pattern', unicode, None), + ) + + def _convert_scalar(self, value, index=None): + """ + Convert a single scalar value. + """ + if type(value) is self.type: + return value + if type(value) in (int, float): + return self.type(value) + raise ConversionError(name=self.name, index=index, + error=ugettext(self.type_error), + ) + + def _rule_minlength(self, _, value): + """ + Check minlength constraint. + """ + assert type(value) is unicode + if len(value) < self.minlength: + return _('must be at least %(minlength)d characters') % dict( + minlength=self.minlength, + ) + + def _rule_maxlength(self, _, value): + """ + Check maxlength constraint. + """ + assert type(value) is unicode + if len(value) > self.maxlength: + return _('can be at most %(maxlength)d characters') % dict( + maxlength=self.maxlength, + ) + + def _rule_length(self, _, value): + """ + Check length constraint. + """ + assert type(value) is unicode + if len(value) != self.length: + return _('must be exactly %(length)d characters') % dict( + length=self.length, + ) + + +def create_param(spec): + """ + Create an `Str` instance from the shorthand ``spec``. + + This function allows you to create `Str` parameters (the most common) from + a convenient shorthand that defines the parameter name, whether it is + required, and whether it is multivalue. (For the definition of the + shorthand syntax, see the `parse_param_spec()` function.) + + If ``spec`` is an ``str`` instance, it will be used to create a new `Str` + parameter, which will be returned. For example: + + >>> s = create_param('hometown?') + >>> s + Str('hometown?') + >>> (s.name, s.required, s.multivalue) + ('hometown', False, False) + + On the other hand, if ``spec`` is already a `Param` instance, it is + returned unchanged. For example: + + >>> b = Bytes('cert') + >>> create_param(b) is b + True + + As a plugin author, you will not call this function directly (which would + be no more convenient than simply creating the `Str` instance). Instead, + `frontend.Command` will call it for you when it evaluates the + ``takes_args`` and ``takes_options`` attributes, and `frontend.Object` + will call it for you when it evaluates the ``takes_params`` attribute. + + :param spec: A spec string or a `Param` instance. + """ + if isinstance(spec, Param): + return spec + if type(spec) is not str: + raise TypeError( + TYPE_ERROR % ('spec', (str, Param), spec, type(spec)) + ) + return Str(spec) -- cgit