Completed Param.use_in_context() functionality, which is now used by Command and Object

This commit is contained in:
Jason Gerard DeRose 2009-05-20 15:19:09 -06:00 committed by Rob Crittenden
parent 7b93f7bbd7
commit 7e58b29a92
6 changed files with 403 additions and 98 deletions

View File

@ -409,9 +409,9 @@ For example:
>>> class nudge(Command):
... """Takes one argument, one option"""
...
... takes_args = ['programmer']
... takes_args = ('programmer',)
...
... takes_options = [Str('stuff', default=u'documentation')]
... takes_options = (Str('stuff', default=u'documentation'))
...
... def execute(self, programmer, **kw):
... return '%s, go write more %s!' % (programmer, kw['stuff'])
@ -462,7 +462,7 @@ here is a quick teaser:
>>> from ipalib import Int
>>> class create_player(Command):
... takes_options = [
... takes_options = (
... 'first',
... 'last',
... Str('nick',
@ -470,7 +470,7 @@ here is a quick teaser:
... default_from=lambda first, last: first[0] + last,
... ),
... Int('points', default=0),
... ]
... )
...
>>> cp = create_player()
>>> cp.finalize()
@ -573,9 +573,9 @@ For example, say we setup a command like this:
>>> class show_items(Command):
...
... takes_args = ['key?']
... takes_args = ('key?',)
...
... takes_options = [Flag('reverse')]
... takes_options = (Flag('reverse'),)
...
... def execute(self, key, **options):
... items = dict(
@ -660,7 +660,7 @@ For example:
>>> class paint_house(Command):
...
... takes_args = ['color']
... takes_args = 'color'
...
... def execute(self, color):
... """Uses self.log.error()"""

View File

@ -23,8 +23,8 @@ Base classes for all front-end plugins.
import re
import inspect
import plugable
from plugable import lock, check_name
from base import lock, check_name, NameSpace
from plugable import Plugin
from parameters import create_param, parse_param_spec, Param, Str, Flag, Password
from util import make_repr
@ -43,12 +43,162 @@ def is_rule(obj):
return callable(obj) and getattr(obj, RULE_FLAG, False) is True
class UsesParams(plugable.Plugin):
class HasParam(Plugin):
"""
Base class for plugins that use param namespaces.
Base class for plugins that have `Param` `NameSpace` attributes.
Subclasses of `HasParam` will on one or more attributes store `NameSpace`
instances containing zero or more `Param` instances. These parameters might
describe, for example, the arguments and options a command takes, or the
attributes an LDAP entry can include, or whatever else the subclass sees
fit.
Although the interface a subclass must implement is very simple, it must
conform to a specific naming convention: if you want a namespace
``SubClass.foo``, you must define a ``Subclass.takes_foo`` attribute and a
``SubCLass.get_foo()`` method, and you may optionally define a
``SubClass.check_foo()`` method.
A quick big-picture example
===========================
Say you want the ``options`` instance attribute on your subclass to be a
`Param` `NameSpace`... then according to the enforced naming convention,
your subclass must define a ``takes_options`` attribute and a
``get_options()`` method. For example:
>>> from ipalib import Str, Int
>>> class Example(HasParam):
...
... options = None # This will be replaced with your namespace
...
... takes_options = (Str('one'), Int('two'))
...
... def get_options(self):
... return self._get_param_iterable('options')
...
>>> eg = Example()
The ``Example.takes_options`` attribute is a ``tuple`` defining the
parameters you want your ``Example.options`` namespace to contain. Your
``Example.takes_options`` attribute will be accessed via
`HasParam._get_param_iterable()`, which, among other things, enforces the
``('takes_' + name)`` naming convention. For example:
>>> eg._get_param_iterable('options')
(Str('one'), Int('two'))
The ``Example.get_options()`` method simply returns
``Example.takes_options`` by calling `HasParam._get_param_iterable()`. Your
``Example.get_options()`` method will be called via
`HasParam._filter_param_by_context()`, which, among other things, enforces
the ``('get_' + name)`` naming convention. For example:
>>> list(eg._filter_param_by_context('options'))
[Str('one'), Int('two')]
At this point, the ``eg.options`` instance attribute is still ``None``:
>>> eg.options is None
True
`HasParam._create_param_namespace()` will create the ``eg.options``
namespace from the parameters yielded by
`HasParam._filter_param_by_context()`. For example:
>>> eg._create_param_namespace('options')
>>> eg.options
NameSpace(<2 members>, sort=False)
>>> list(eg.options) # Like dict.__iter__()
['one', 'two']
Your subclass can optionally define a ``check_options()`` method to perform
sanity checks. If it exists, the ``check_options()`` method is called by
`HasParam._create_param_namespace()` with a single value, the `NameSpace`
instance it created. For example:
>>> class Example2(Example):
...
... def check_options(self, namespace):
... for param in namespace(): # Like dict.itervalues()
... if param.name == 'three':
... raise ValueError("I dislike the param 'three'")
... print ' ** Looks good! **' # Note output below
...
>>> eg = Example2()
>>> eg._create_param_namespace('options')
** Looks good! **
>>> eg.options
NameSpace(<2 members>, sort=False)
However, if we subclass again and add a `Param` named ``'three'``:
>>> class Example3(Example2):
...
... takes_options = (Str('one'), Int('two'), Str('three'))
...
>>> eg = Example3()
>>> eg._create_param_namespace('options')
Traceback (most recent call last):
...
ValueError: I dislike the param 'three'
>>> eg.options is None # eg.options was not set
True
The Devil and the details
=========================
In the above example, ``takes_options`` is a ``tuple``, but it can also be
a param spec (see `create_param()`), or a callable that returns an iterable
containing one or more param spec. Regardless of how ``takes_options`` is
defined, `HasParam._get_param_iterable()` will return a uniform iterable,
conveniently hiding the details.
The above example uses the simplest ``get_options()`` method possible, but
you could instead implement a ``get_options()`` method that would, for
example, produce (or withhold) certain parameters based on the whether
certain plugins are loaded.
Think of ``takes_options`` as declarative, a simple definition of *what*
parameters should be included in the namespace. You should only implement
a ``takes_options()`` method if a `Param` must reference attributes on your
plugin instance (for example, for validation rules); you should not use a
``takes_options()`` method to filter the parameters or add any other
procedural behaviour.
On the other hand, think of the ``get_options()`` method as imperative, a
procedure for *how* the parameters should be created and filtered. In the
example above the *how* just returns the *what* unchanged, but arbitrary
logic can be implemented in the ``get_options()`` method. For example, you
might filter certain parameters from ``takes_options`` base on some
criteria, or you might insert additional parameters provided by other
plugins.
The typical use case for using ``get_options()`` this way is to procedurally
generate the arguments and options for all the CRUD commands operating on a
specific LDAP object: the `Object` plugin defines the possible LDAP entry
attributes (as `Param`), and then the CRUD commands intelligently build
their ``args`` and ``options`` namespaces based on which attribute is the
primary key. In this way new LDAP attributes (aka parameters) can be added
to the single point of definition (the `Object` plugin), and all the
corresponding CRUD commands pick up these new parameters without requiring
modification. For an example of how this is done, see the
`ipalib.crud.Create` base class.
However, there is one type of filtering you should not implement in your
``get_options()`` method, because it's already provided at a higher level:
you should not filter parameters based on the value of ``api.env.context``
nor (preferably) on any values in ``api.env``.
`HasParam._filter_param_by_context()` already does this by calling
`Param.use_in_context()` for each parameter. Although the base
`Param.use_in_context()` implementation makes a decision solely on the value
of ``api.env.context``, subclasses can override this with implementations
that consider arbitrary ``api.env`` values.
"""
def _get_params_iterable(self, name):
def _get_param_iterable(self, name):
"""
Return an iterable of params defined by the attribute named ``name``.
@ -59,18 +209,18 @@ class UsesParams(plugable.Plugin):
For example, when defined with a tuple:
>>> class ByTuple(UsesParams):
>>> class ByTuple(HasParam):
... takes_args = (Param('foo'), Param('bar'))
...
>>> by_tuple = ByTuple()
>>> list(by_tuple._get_params_iterable('takes_args'))
>>> list(by_tuple._get_param_iterable('args'))
[Param('foo'), Param('bar')]
Or you can define your param sequence with a callable when you need to
reference attributes on your plugin instance (for validation rules,
etc.). For example:
>>> class ByCallable(UsesParams):
>>> class ByCallable(HasParam):
... def takes_args(self):
... yield Param('foo', self.validate_foo)
... yield Param('bar', self.validate_bar)
@ -84,71 +234,105 @@ class UsesParams(plugable.Plugin):
... return _("must be 'Bar'")
...
>>> by_callable = ByCallable()
>>> list(by_callable._get_params_iterable('takes_args'))
>>> list(by_callable._get_param_iterable('args'))
[Param('foo', validate_foo), Param('bar', validate_bar)]
Lastly, as a convenience for when a param sequence contains a single
param, your defining attribute may a param spec (either a `Param`
or an ``str`` instance). For example:
>>> class BySpec(UsesParams):
>>> class BySpec(HasParam):
... takes_args = Param('foo')
... takes_options = 'bar?'
...
>>> by_spec = BySpec()
>>> list(by_spec._get_params_iterable('takes_args'))
>>> list(by_spec._get_param_iterable('args'))
[Param('foo')]
>>> list(by_spec._get_params_iterable('takes_options'))
>>> list(by_spec._get_param_iterable('options'))
['bar?']
For information on how an ``str`` param spec is interpreted, see the
`create_param()` and `parse_param_spec()` functions in the
`ipalib.parameters` module.
Also see `UsesParams._filter_params_by_context()`.
Also see `HasParam._filter_param_by_context()`.
"""
attr = getattr(self, name)
if isinstance(attr, (Param, str)):
return (attr,)
if callable(attr):
return attr()
return attr
takes_name = 'takes_' + name
takes = getattr(self, takes_name, None)
if type(takes) is tuple:
return takes
if isinstance(takes, (Param, str)):
return (takes,)
if callable(takes):
return takes()
if takes is None:
return tuple()
raise TypeError(
'%s.%s must be a tuple, callable, or spec; got %r' % (
self.name, takes_name, takes
)
)
def _filter_params_by_context(self, name, env=None):
def _filter_param_by_context(self, name, env=None):
"""
Filter params on attribute named ``name`` by environment ``env``.
For example:
>>> from ipalib.config import Env
>>> class Example(UsesParams):
>>> class Example(HasParam):
...
... takes_args = (
... Str('foo_only', include=['foo']),
... Str('not_bar', exclude=['bar']),
... 'both',
... )
...
... def get_args(self):
... return self._get_param_iterable('args')
...
...
>>> eg = Example()
>>> foo = Env(context='foo')
>>> bar = Env(context='bar')
>>> another = Env(context='another')
>>> (foo.context, bar.context, another.context)
('foo', 'bar', 'another')
>>> list(eg._filter_params_by_context('takes_args', foo))
>>> list(eg._filter_param_by_context('args', foo))
[Str('foo_only', include=['foo']), Str('not_bar', exclude=['bar']), Str('both')]
>>> list(eg._filter_params_by_context('takes_args', bar))
>>> list(eg._filter_param_by_context('args', bar))
[Str('both')]
>>> list(eg._filter_params_by_context('takes_args', another))
>>> list(eg._filter_param_by_context('args', another))
[Str('not_bar', exclude=['bar']), Str('both')]
"""
env = getattr(self, 'env', env)
for spec in self._get_params_iterable(name):
get_name = 'get_' + name
if not hasattr(self, get_name):
raise NotImplementedError(
'%s.%s()' % (self.name, get_name)
)
get = getattr(self, get_name)
if not callable(get):
raise TypeError(
'%s.%s must be a callable; got %r' % (self.name, get_name, get)
)
for spec in get():
param = create_param(spec)
if env is None or param.use_in_context(env):
yield param
def _create_param_namespace(self, name, env=None):
namespace = NameSpace(
self._filter_param_by_context(name, env),
sort=False
)
check = getattr(self, 'check_' + name, None)
if callable(check):
check(namespace)
setattr(self, name, namespace)
class Command(plugable.Plugin):
class Command(HasParam):
"""
A public IPA atomic operation.
@ -372,7 +556,7 @@ class Command(plugable.Plugin):
>>> from ipalib import Str
>>> class my_command(Command):
... takes_args = [Str('color', default=u'Red')]
... takes_args = Str('color', default=u'Red')
...
>>> c = my_command()
>>> c.finalize()
@ -453,65 +637,46 @@ class Command(plugable.Plugin):
loaded in self.api to determine what their custom `Command.get_args`
and `Command.get_options` methods should yield.
"""
self.args = plugable.NameSpace(self.__create_args(), sort=False)
self._create_param_namespace('args')
if len(self.args) == 0 or not self.args[-1].multivalue:
self.max_args = len(self.args)
else:
self.max_args = None
self.options = plugable.NameSpace(
(create_param(spec) for spec in self.get_options()),
sort=False
)
self._create_param_namespace('options')
def get_key(p):
if p.required:
if p.default_from is None:
return 0
return 1
return 2
self.params = plugable.NameSpace(
self.params = NameSpace(
sorted(tuple(self.args()) + tuple(self.options()), key=get_key),
sort=False
)
super(Command, self).finalize()
def _get_takes(self, name):
attr = getattr(self, name)
if isinstance(attr, (Param, str)):
return (attr,)
if callable(attr):
return attr()
return attr
def get_args(self):
"""
Iterate through parameters for ``Command.args`` namespace.
Subclasses can override this to customize how the arguments
are determined. For an example of why this can be useful,
see `ipalib.crud.Mod`.
This method gets called by `HasParam._create_param_namespace()`.
Subclasses can override this to customize how the arguments are
determined. For an example of why this can be useful, see the
`ipalib.crud.Create` subclass.
"""
for arg in self._get_takes('takes_args'):
for arg in self._get_param_iterable('args'):
yield arg
def get_options(self):
def check_args(self, args):
"""
Iterate through parameters for ``Command.options`` namespace.
Sanity test for args namespace.
Subclasses can override this to customize how the options
are determined. For an example of why this can be useful,
see `ipalib.crud.Mod`.
"""
for option in self._get_takes('takes_options'):
yield option
def __create_args(self):
"""
Generator used to create args namespace.
This method gets called by `HasParam._create_param_namespace()`.
"""
optional = False
multivalue = False
for arg in self.get_args():
arg = create_param(arg)
for arg in args():
if optional and arg.required:
raise ValueError(
'%s: required argument after optional' % arg.name
@ -524,7 +689,19 @@ class Command(plugable.Plugin):
optional = True
if arg.multivalue:
multivalue = True
yield arg
def get_options(self):
"""
Iterate through parameters for ``Command.options`` namespace.
This method gets called by `HasParam._create_param_namespace()`.
Subclasses can override this to customize how the arguments are
determined. For an example of why this can be useful, see the
`ipalib.crud.Create` subclass.
"""
for option in self._get_param_iterable('options'):
yield option
class LocalOrRemote(Command):
@ -558,7 +735,7 @@ class LocalOrRemote(Command):
return self.execute(*args, **options)
class Object(plugable.Plugin):
class Object(HasParam):
__public__ = frozenset((
'backend',
'methods',
@ -582,15 +759,13 @@ class Object(plugable.Plugin):
def set_api(self, api):
super(Object, self).set_api(api)
self.methods = plugable.NameSpace(
self.methods = NameSpace(
self.__get_attrs('Method'), sort=False
)
self.properties = plugable.NameSpace(
self.properties = NameSpace(
self.__get_attrs('Property'), sort=False
)
self.params = plugable.NameSpace(
self.__get_params(), sort=False
)
self._create_param_namespace('params')
pkeys = filter(lambda p: p.primary_key, self.params())
if len(pkeys) > 1:
raise ValueError(
@ -601,7 +776,7 @@ class Object(plugable.Plugin):
)
if len(pkeys) == 1:
self.primary_key = pkeys[0]
self.params_minus_pk = plugable.NameSpace(
self.params_minus_pk = NameSpace(
filter(lambda p: not p.primary_key, self.params()), sort=False
)
@ -630,14 +805,17 @@ class Object(plugable.Plugin):
if name not in self.api:
return
namespace = self.api[name]
assert type(namespace) is plugable.NameSpace
assert type(namespace) is NameSpace
for proxy in namespace(): # Equivalent to dict.itervalues()
if proxy.obj_name == self.name:
yield proxy.__clone__('attr_name')
def __get_params(self):
def get_params(self):
"""
This method gets called by `HasParam._create_param_namespace()`.
"""
props = self.properties.__todict__()
for spec in self.takes_params:
for spec in self._get_param_iterable('params'):
if type(spec) is str:
key = spec.rstrip('?*+')
else:
@ -657,7 +835,7 @@ class Object(plugable.Plugin):
yield prop.param
class Attribute(plugable.Plugin):
class Attribute(Plugin):
"""
Base class implementing the attribute-to-object association.

View File

@ -381,24 +381,54 @@ class Param(ReadOnly):
def use_in_context(self, env):
"""
Return ``True`` if this param should be used in ``env.context``.
Return ``True`` if this parameter should be used in ``env.context``.
For example:
If a parameter is created with niether the ``include`` nor the
``exclude`` kwarg, this method will always return ``True``. For
example:
>>> from ipalib.config import Env
>>> server = Env()
>>> server.context = 'server'
>>> client = Env()
>>> client.context = 'client'
>>> param = Param('my_param', include=['server', 'webui'])
>>> param.use_in_context(server)
>>> param = Param('my_param')
>>> param.use_in_context(Env(context='foo'))
True
>>> param.use_in_context(client)
>>> param.use_in_context(Env(context='bar'))
True
If a parameter is created with an ``include`` kwarg, this method will
only return ``True`` if ``env.context`` is in ``include``. For example:
>>> param = Param('my_param', include=['foo', 'whatever'])
>>> param.include
frozenset(['foo', 'whatever'])
>>> param.use_in_context(Env(context='foo'))
True
>>> param.use_in_context(Env(context='bar'))
False
So that a subclass can add additional logic basic on other environment
variables, the `config.Env` instance is passed in rather than just the
value of ``env.context``.
If a paremeter is created with an ``exclude`` kwarg, this method will
only return ``True`` if ``env.context`` is not in ``exclude``. For
example:
>>> param = Param('my_param', exclude=['foo', 'whatever'])
>>> param.exclude
frozenset(['foo', 'whatever'])
>>> param.use_in_context(Env(context='foo'))
False
>>> param.use_in_context(Env(context='bar'))
True
Note that the ``include`` and ``exclude`` kwargs are mutually exclusive
and that at most one can be suppelied to `Param.__init__()`. For
example:
>>> param = Param('nope', include=['foo'], exclude=['bar'])
Traceback (most recent call last):
...
ValueError: Param('nope'): cannot have both include=frozenset(['foo']) and exclude=frozenset(['bar'])
So that subclasses can add additional logic based on other environment
variables, the entire `config.Env` instance is passed in rather than
just the value of ``env.context``.
"""
if self.include is not None:
return (env.context in self.include)

View File

@ -172,8 +172,8 @@ class test_Executioner(ClassChecker):
(api, home) = create_test_api(in_server=True)
class echo(Command):
takes_args = ['arg1', 'arg2+']
takes_options = ['option1?', 'option2?']
takes_args = ('arg1', 'arg2+')
takes_options = ('option1?', 'option2?')
def execute(self, *args, **options):
assert type(args[1]) is tuple
return args + (options,)
@ -196,7 +196,7 @@ class test_Executioner(ClassChecker):
"""
Test that a kwarg named 'name' can be used.
"""
takes_options=['name']
takes_options = 'name'
def execute(self, **options):
return options['name'].upper()
api.register(with_name)

View File

@ -30,7 +30,7 @@ class CrudChecker(ClassChecker):
Class for testing base classes in `ipalib.crud`.
"""
def get_api(self, args=tuple(), options={}):
def get_api(self, args=tuple(), options=tuple()):
"""
Return a finalized `ipalib.plugable.API` instance.
"""

View File

@ -72,6 +72,105 @@ def test_is_rule():
assert not is_rule(call(None))
class test_HasParam(ClassChecker):
"""
Test the `ipalib.frontend.Command` class.
"""
_cls = frontend.HasParam
def test_get_param_iterable(self):
"""
Test the `ipalib.frontend.HasParam._get_param_iterable` method.
"""
class WithTuple(self.cls):
takes_stuff = ('one', 'two')
o = WithTuple()
assert o._get_param_iterable('stuff') is WithTuple.takes_stuff
junk = ('three', 'four')
class WithCallable(self.cls):
def takes_stuff(self):
return junk
o = WithCallable()
assert o._get_param_iterable('stuff') is junk
class WithParam(self.cls):
takes_stuff = parameters.Str('five')
o = WithParam()
assert o._get_param_iterable('stuff') == (WithParam.takes_stuff,)
class WithStr(self.cls):
takes_stuff = 'six'
o = WithStr()
assert o._get_param_iterable('stuff') == ('six',)
class Wrong(self.cls):
takes_stuff = ['seven', 'eight']
o = Wrong()
e = raises(TypeError, o._get_param_iterable, 'stuff')
assert str(e) == '%s.%s must be a tuple, callable, or spec; got %r' % (
'Wrong', 'takes_stuff', Wrong.takes_stuff
)
def test_filter_param_by_context(self):
"""
Test the `ipalib.frontend.HasParam._filter_param_by_context` method.
"""
class Example(self.cls):
def get_stuff(self):
return (
'one', # Make sure create_param() is called for each spec
'two',
parameters.Str('three', include='cli'),
parameters.Str('four', exclude='server'),
parameters.Str('five', exclude=['whatever', 'cli']),
)
o = Example()
# Test when env is None:
params = list(o._filter_param_by_context('stuff'))
assert list(p.name for p in params) == [
'one', 'two', 'three', 'four', 'five'
]
for p in params:
assert type(p) is parameters.Str
# Test when env.context == 'cli':
cli = config.Env(context='cli')
assert cli.context == 'cli'
params = list(o._filter_param_by_context('stuff', cli))
assert list(p.name for p in params) == ['one', 'two', 'three', 'four']
for p in params:
assert type(p) is parameters.Str
# Test when env.context == 'server'
server = config.Env(context='server')
assert server.context == 'server'
params = list(o._filter_param_by_context('stuff', server))
assert list(p.name for p in params) == ['one', 'two', 'five']
for p in params:
assert type(p) is parameters.Str
# Test with no get_stuff:
class Missing(self.cls):
pass
o = Missing()
gen = o._filter_param_by_context('stuff')
e = raises(NotImplementedError, list, gen)
assert str(e) == 'Missing.get_stuff()'
# Test when get_stuff is not callable:
class NotCallable(self.cls):
get_stuff = ('one', 'two')
o = NotCallable()
gen = o._filter_param_by_context('stuff')
e = raises(TypeError, list, gen)
assert str(e) == '%s.%s must be a callable; got %r' % (
'NotCallable', 'get_stuff', NotCallable.get_stuff
)
class test_Command(ClassChecker):
"""
Test the `ipalib.frontend.Command` class.
@ -125,7 +224,6 @@ class test_Command(ClassChecker):
"""
Test the `ipalib.frontend.Command` class.
"""
assert self.cls.__bases__ == (plugable.Plugin,)
assert self.cls.takes_options == tuple()
assert self.cls.takes_args == tuple()
@ -380,7 +478,7 @@ class test_Command(ClassChecker):
Test the `ipalib.frontend.Command.params_2_args_options` method.
"""
assert 'params_2_args_options' in self.cls.__public__ # Public
o = self.get_instance(args=['one'], options=['two'])
o = self.get_instance(args='one', options='two')
assert o.params_2_args_options() == ((None,), {})
assert o.params_2_args_options(one=1) == ((1,), {})
assert o.params_2_args_options(two=2) == ((None,), dict(two=2))
@ -440,7 +538,7 @@ class test_LocalOrRemote(ClassChecker):
Test the `ipalib.frontend.LocalOrRemote.run` method.
"""
class example(self.cls):
takes_args = ['key?']
takes_args = 'key?'
def forward(self, *args, **options):
return ('forward', args, options)
@ -481,7 +579,6 @@ class test_Object(ClassChecker):
"""
Test the `ipalib.frontend.Object` class.
"""
assert self.cls.__bases__ == (plugable.Plugin,)
assert self.cls.backend is None
assert self.cls.methods is None
assert self.cls.properties is None