diff options
author | Rob Crittenden <rcritten@redhat.com> | 2009-01-22 10:42:41 -0500 |
---|---|---|
committer | Rob Crittenden <rcritten@redhat.com> | 2009-01-22 10:42:41 -0500 |
commit | c2967a675a288e7d31374229fd974d0cb9966f2c (patch) | |
tree | 58be8ca6319f4660d9f18b97a37b9c0c56104d02 /ipalib/plugable.py | |
parent | 2b8b87b4d6c3b4389a0a7bf48c225035c53e7ad1 (diff) | |
parent | 5d82e3b35a8fb2d4c25f282cddad557a7650197c (diff) | |
download | freeipa.git-c2967a675a288e7d31374229fd974d0cb9966f2c.tar.gz freeipa.git-c2967a675a288e7d31374229fd974d0cb9966f2c.tar.xz freeipa.git-c2967a675a288e7d31374229fd974d0cb9966f2c.zip |
Merge branch 'master' of git://fedorapeople.org/~jderose/freeipa2
Diffstat (limited to 'ipalib/plugable.py')
-rw-r--r-- | ipalib/plugable.py | 718 |
1 files changed, 718 insertions, 0 deletions
diff --git a/ipalib/plugable.py b/ipalib/plugable.py new file mode 100644 index 00000000..b52db900 --- /dev/null +++ b/ipalib/plugable.py @@ -0,0 +1,718 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# 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 + +""" +Plugin framework. + +The classes in this module make heavy use of Python container emulation. If +you are unfamiliar with this Python feature, see +http://docs.python.org/ref/sequence-types.html +""" + +import re +import sys +import inspect +import threading +import logging +import os +from os import path +import subprocess +import errors2 +from config import Env +import util +from base import ReadOnly, NameSpace, lock, islocked, check_name +from constants import DEFAULT_CONFIG + + +class SetProxy(ReadOnly): + """ + A read-only container with set/sequence behaviour. + + This container acts as a proxy to an actual set-like object (a set, + frozenset, or dict) that is passed to the constructor. To the extent + possible in Python, this underlying set-like object cannot be modified + through the SetProxy... which just means you wont do it accidentally. + """ + def __init__(self, s): + """ + :param s: The target set-like object (a set, frozenset, or dict) + """ + allowed = (set, frozenset, dict) + if type(s) not in allowed: + raise TypeError('%r not in %r' % (type(s), allowed)) + self.__s = s + lock(self) + + def __len__(self): + """ + Return the number of items in this container. + """ + return len(self.__s) + + def __iter__(self): + """ + Iterate (in ascending order) through keys. + """ + for key in sorted(self.__s): + yield key + + def __contains__(self, key): + """ + Return True if this container contains ``key``. + + :param key: The key to test for membership. + """ + return key in self.__s + + +class DictProxy(SetProxy): + """ + A read-only container with mapping behaviour. + + This container acts as a proxy to an actual mapping object (a dict) that + is passed to the constructor. To the extent possible in Python, this + underlying mapping object cannot be modified through the DictProxy... + which just means you wont do it accidentally. + + Also see `SetProxy`. + """ + def __init__(self, d): + """ + :param d: The target mapping object (a dict) + """ + if type(d) is not dict: + raise TypeError('%r is not %r' % (type(d), dict)) + self.__d = d + super(DictProxy, self).__init__(d) + + def __getitem__(self, key): + """ + Return the value corresponding to ``key``. + + :param key: The key of the value you wish to retrieve. + """ + return self.__d[key] + + def __call__(self): + """ + Iterate (in ascending order by key) through values. + """ + for key in self: + yield self.__d[key] + + +class MagicDict(DictProxy): + """ + A mapping container whose values can be accessed as attributes. + + For example: + + >>> magic = MagicDict({'the_key': 'the value'}) + >>> magic['the_key'] + 'the value' + >>> magic.the_key + 'the value' + + This container acts as a proxy to an actual mapping object (a dict) that + is passed to the constructor. To the extent possible in Python, this + underlying mapping object cannot be modified through the MagicDict... + which just means you wont do it accidentally. + + Also see `DictProxy` and `SetProxy`. + """ + + def __getattr__(self, name): + """ + Return the value corresponding to ``name``. + + :param name: The name of the attribute you wish to retrieve. + """ + try: + return self[name] + except KeyError: + raise AttributeError('no magic attribute %r' % name) + + +class Plugin(ReadOnly): + """ + Base class for all plugins. + """ + __public__ = frozenset() + __proxy__ = True + __api = None + + def __init__(self): + cls = self.__class__ + self.name = cls.__name__ + self.module = cls.__module__ + self.fullname = '%s.%s' % (self.module, self.name) + self.doc = inspect.getdoc(cls) + if self.doc is None: + self.summary = '<%s>' % self.fullname + else: + self.summary = self.doc.split('\n\n', 1)[0] + log = logging.getLogger(self.fullname) + for name in ('debug', 'info', 'warning', 'error', 'critical', 'exception'): + if hasattr(self, name): + raise StandardError( + '%s.%s attribute (%r) conflicts with Plugin logger' % ( + self.name, name, getattr(self, name)) + ) + setattr(self, name, getattr(log, name)) + + def __get_api(self): + """ + Return `API` instance passed to `finalize()`. + + If `finalize()` has not yet been called, None is returned. + """ + return self.__api + api = property(__get_api) + + @classmethod + def implements(cls, arg): + """ + Return True if this class implements ``arg``. + + There are three different ways this method can be called: + + With a <type 'str'> argument, e.g.: + + >>> class base(Plugin): + ... __public__ = frozenset(['attr1', 'attr2']) + ... + >>> base.implements('attr1') + True + >>> base.implements('attr2') + True + >>> base.implements('attr3') + False + + With a <type 'frozenset'> argument, e.g.: + + With any object that has a `__public__` attribute that is + <type 'frozenset'>, e.g.: + + Unlike ProxyTarget.implemented_by(), this returns an abstract answer + because only the __public__ frozenset is checked... a ProxyTarget + need not itself have attributes for all names in __public__ + (subclasses might provide them). + """ + assert type(cls.__public__) is frozenset + if isinstance(arg, str): + return arg in cls.__public__ + if type(getattr(arg, '__public__', None)) is frozenset: + return cls.__public__.issuperset(arg.__public__) + if type(arg) is frozenset: + return cls.__public__.issuperset(arg) + raise TypeError( + "must be str, frozenset, or have frozenset '__public__' attribute" + ) + + @classmethod + def implemented_by(cls, arg): + """ + Return True if ``arg`` implements public interface of this class. + + This classmethod returns True if: + + 1. ``arg`` is an instance of or subclass of this class, and + + 2. ``arg`` (or ``arg.__class__`` if instance) has an attribute for + each name in this class's ``__public__`` frozenset. + + Otherwise, returns False. + + Unlike `Plugin.implements`, this returns a concrete answer because + the attributes of the subclass are checked. + + :param arg: An instance of or subclass of this class. + """ + if inspect.isclass(arg): + subclass = arg + else: + subclass = arg.__class__ + assert issubclass(subclass, cls), 'must be subclass of %r' % cls + for name in cls.__public__: + if not hasattr(subclass, name): + return False + return True + + def finalize(self): + """ + """ + lock(self) + + def set_api(self, api): + """ + Set reference to `API` instance. + """ + assert self.__api is None, 'set_api() can only be called once' + assert api is not None, 'set_api() argument cannot be None' + self.__api = api + if not isinstance(api, API): + return + for name in api: + assert not hasattr(self, name) + setattr(self, name, api[name]) + # FIXME: the 'log' attribute is depreciated. See Plugin.__init__() + for name in ('env', 'context', 'log'): + if hasattr(api, name): + assert not hasattr(self, name) + setattr(self, name, getattr(api, name)) + + def call(self, executable, *args): + """ + Call ``executable`` with ``args`` using subprocess.call(). + + If the call exits with a non-zero exit status, + `ipalib.errors2.SubprocessError` is raised, from which you can retrieve + the exit code by checking the SubprocessError.returncode attribute. + + This method does *not* return what ``executable`` sent to stdout... for + that, use `Plugin.callread()`. + """ + argv = (executable,) + args + self.debug('Calling %r', argv) + code = subprocess.call(argv) + if code != 0: + raise errors2.SubprocessError(returncode=code, argv=argv) + + def __repr__(self): + """ + Return 'module_name.class_name()' representation. + + This representation could be used to instantiate this Plugin + instance given the appropriate environment. + """ + return '%s.%s()' % ( + self.__class__.__module__, + self.__class__.__name__ + ) + + +class PluginProxy(SetProxy): + """ + Allow access to only certain attributes on a `Plugin`. + + Think of a proxy as an agreement that "I will have at most these + attributes". This is different from (although similar to) an interface, + which can be thought of as an agreement that "I will have at least these + attributes". + """ + + __slots__ = ( + '__base', + '__target', + '__name_attr', + '__public__', + 'name', + 'doc', + ) + + def __init__(self, base, target, name_attr='name'): + """ + :param base: A subclass of `Plugin`. + :param target: An instance ``base`` or a subclass of ``base``. + :param name_attr: The name of the attribute on ``target`` from which + to derive ``self.name``. + """ + if not inspect.isclass(base): + raise TypeError( + '`base` must be a class, got %r' % base + ) + if not isinstance(target, base): + raise ValueError( + '`target` must be an instance of `base`, got %r' % target + ) + self.__base = base + self.__target = target + self.__name_attr = name_attr + self.__public__ = base.__public__ + self.name = getattr(target, name_attr) + self.doc = target.doc + assert type(self.__public__) is frozenset + super(PluginProxy, self).__init__(self.__public__) + + def implements(self, arg): + """ + Return True if plugin being proxied implements ``arg``. + + This method simply calls the corresponding `Plugin.implements` + classmethod. + + Unlike `Plugin.implements`, this is not a classmethod as a + `PluginProxy` can only implement anything as an instance. + """ + return self.__base.implements(arg) + + def __clone__(self, name_attr): + """ + Return a `PluginProxy` instance similar to this one. + + The new `PluginProxy` returned will be identical to this one except + the proxy name might be derived from a different attribute on the + target `Plugin`. The same base and target will be used. + """ + return self.__class__(self.__base, self.__target, name_attr) + + def __getitem__(self, key): + """ + Return attribute named ``key`` on target `Plugin`. + + If this proxy allows access to an attribute named ``key``, that + attribute will be returned. If access is not allowed, + KeyError will be raised. + """ + if key in self.__public__: + return getattr(self.__target, key) + raise KeyError('no public attribute %s.%s' % (self.name, key)) + + def __getattr__(self, name): + """ + Return attribute named ``name`` on target `Plugin`. + + If this proxy allows access to an attribute named ``name``, that + attribute will be returned. If access is not allowed, + AttributeError will be raised. + """ + if name in self.__public__: + return getattr(self.__target, name) + raise AttributeError('no public attribute %s.%s' % (self.name, name)) + + def __call__(self, *args, **kw): + """ + Call target `Plugin` and return its return value. + + If `__call__` is not an attribute this proxy allows access to, + KeyError is raised. + """ + return self['__call__'](*args, **kw) + + def __repr__(self): + """ + Return a Python expression that could create this instance. + """ + return '%s(%s, %r)' % ( + self.__class__.__name__, + self.__base.__name__, + self.__target, + ) + + +class Registrar(DictProxy): + """ + Collects plugin classes as they are registered. + + The Registrar does not instantiate plugins... it only implements the + override logic and stores the plugins in a namespace per allowed base + class. + + The plugins are instantiated when `API.finalize()` is called. + """ + def __init__(self, *allowed): + """ + :param allowed: Base classes from which plugins accepted by this + Registrar must subclass. + """ + self.__allowed = dict((base, {}) for base in allowed) + self.__registered = set() + super(Registrar, self).__init__( + dict(self.__base_iter()) + ) + + def __base_iter(self): + for (base, sub_d) in self.__allowed.iteritems(): + assert inspect.isclass(base) + name = base.__name__ + assert not hasattr(self, name) + setattr(self, name, MagicDict(sub_d)) + yield (name, base) + + def __findbases(self, klass): + """ + Iterates through allowed bases that ``klass`` is a subclass of. + + Raises `errors2.PluginSubclassError` if ``klass`` is not a subclass of + any allowed base. + + :param klass: The plugin class to find bases for. + """ + assert inspect.isclass(klass) + found = False + for (base, sub_d) in self.__allowed.iteritems(): + if issubclass(klass, base): + found = True + yield (base, sub_d) + if not found: + raise errors2.PluginSubclassError( + plugin=klass, bases=self.__allowed.keys() + ) + + def __call__(self, klass, override=False): + """ + Register the plugin ``klass``. + + :param klass: A subclass of `Plugin` to attempt to register. + :param override: If true, override an already registered plugin. + """ + if not inspect.isclass(klass): + raise TypeError('plugin must be a class; got %r' % klass) + + # Raise DuplicateError if this exact class was already registered: + if klass in self.__registered: + raise errors2.PluginDuplicateError(plugin=klass) + + # Find the base class or raise SubclassError: + for (base, sub_d) in self.__findbases(klass): + # Check override: + if klass.__name__ in sub_d: + if not override: + # Must use override=True to override: + raise errors2.PluginOverrideError( + base=base.__name__, + name=klass.__name__, + plugin=klass, + ) + else: + if override: + # There was nothing already registered to override: + raise errors2.PluginMissingOverrideError( + base=base.__name__, + name=klass.__name__, + plugin=klass, + ) + + # The plugin is okay, add to sub_d: + sub_d[klass.__name__] = klass + + # The plugin is okay, add to __registered: + self.__registered.add(klass) + + +class LazyContext(object): + """ + On-demand creation of thread-local context attributes. + """ + + def __init__(self, api): + self.__api = api + self.__context = threading.local() + + def __getattr__(self, name): + if name not in self.__context.__dict__: + if name not in self.__api.Context: + raise AttributeError('no Context plugin for %r' % name) + value = self.__api.Context[name].get_value() + self.__context.__dict__[name] = value + return self.__context.__dict__[name] + + def __getitem__(self, key): + return self.__getattr__(key) + + + +class API(DictProxy): + """ + Dynamic API object through which `Plugin` instances are accessed. + """ + + def __init__(self, *allowed): + self.__d = dict() + self.__done = set() + self.register = Registrar(*allowed) + self.env = Env() + self.context = LazyContext(self) + super(API, self).__init__(self.__d) + + def __doing(self, name): + if name in self.__done: + raise StandardError( + '%s.%s() already called' % (self.__class__.__name__, name) + ) + self.__done.add(name) + + def __do_if_not_done(self, name): + if name not in self.__done: + getattr(self, name)() + + def isdone(self, name): + return name in self.__done + + def bootstrap(self, **overrides): + """ + Initialize environment variables and logging. + """ + self.__doing('bootstrap') + self.env._bootstrap(**overrides) + self.env._finalize_core(**dict(DEFAULT_CONFIG)) + log = logging.getLogger('ipa') + object.__setattr__(self, 'log', log) + if self.env.debug: + log.setLevel(logging.DEBUG) + else: + log.setLevel(logging.INFO) + + # Add stderr handler: + stderr = logging.StreamHandler() + format = self.env.log_format_stderr + if self.env.debug: + format = self.env.log_format_stderr_debug + stderr.setLevel(logging.DEBUG) + elif self.env.verbose: + stderr.setLevel(logging.INFO) + else: + stderr.setLevel(logging.WARNING) + stderr.setFormatter(util.LogFormatter(format)) + log.addHandler(stderr) + + # Add file handler: + if self.env.mode in ('dummy', 'unit_test'): + return # But not if in unit-test mode + log_dir = path.dirname(self.env.log) + if not path.isdir(log_dir): + try: + os.makedirs(log_dir) + except OSError: + log.warn('Could not create log_dir %r', log_dir) + return + handler = logging.FileHandler(self.env.log) + handler.setFormatter(util.LogFormatter(self.env.log_format_file)) + if self.env.debug: + handler.setLevel(logging.DEBUG) + else: + handler.setLevel(logging.INFO) + log.addHandler(handler) + + def bootstrap_with_global_options(self, options=None, context=None): + if options is None: + parser = util.add_global_options() + (options, args) = parser.parse_args( + list(s.decode('utf-8') for s in sys.argv[1:]) + ) + overrides = {} + if options.env is not None: + assert type(options.env) is list + for item in options.env: + try: + (key, value) = item.split('=', 1) + except ValueError: + # FIXME: this should raise an IPA exception with an + # error code. + # --Jason, 2008-10-31 + pass + overrides[str(key.strip())] = value.strip() + for key in ('conf', 'debug', 'verbose'): + value = getattr(options, key, None) + if value is not None: + overrides[key] = value + if context is not None: + overrides['context'] = context + self.bootstrap(**overrides) + + def load_plugins(self): + """ + Load plugins from all standard locations. + + `API.bootstrap` will automatically be called if it hasn't been + already. + """ + self.__doing('load_plugins') + self.__do_if_not_done('bootstrap') + if self.env.mode in ('dummy', 'unit_test'): + return + util.import_plugins_subpackage('ipalib') + if self.env.in_server: + util.import_plugins_subpackage('ipaserver') + + def finalize(self): + """ + Finalize the registration, instantiate the plugins. + + `API.bootstrap` will automatically be called if it hasn't been + already. + """ + self.__doing('finalize') + self.__do_if_not_done('load_plugins') + + class PluginInstance(object): + """ + Represents a plugin instance. + """ + + i = 0 + + def __init__(self, klass): + self.created = self.next() + self.klass = klass + self.instance = klass() + self.bases = [] + + @classmethod + def next(cls): + cls.i += 1 + return cls.i + + class PluginInfo(ReadOnly): + def __init__(self, p): + assert isinstance(p, PluginInstance) + self.created = p.created + self.name = p.klass.__name__ + self.module = str(p.klass.__module__) + self.plugin = '%s.%s' % (self.module, self.name) + self.bases = tuple(b.__name__ for b in p.bases) + lock(self) + + plugins = {} + def plugin_iter(base, subclasses): + for klass in subclasses: + assert issubclass(klass, base) + if klass not in plugins: + plugins[klass] = PluginInstance(klass) + p = plugins[klass] + assert base not in p.bases + p.bases.append(base) + if base.__proxy__: + yield PluginProxy(base, p.instance) + else: + yield p.instance + + for name in self.register: + base = self.register[name] + magic = getattr(self.register, name) + namespace = NameSpace( + plugin_iter(base, (magic[k] for k in magic)) + ) + assert not ( + name in self.__d or hasattr(self, name) + ) + self.__d[name] = namespace + object.__setattr__(self, name, namespace) + + for p in plugins.itervalues(): + p.instance.set_api(self) + assert p.instance.api is self + + for p in plugins.itervalues(): + p.instance.finalize() + object.__setattr__(self, '_API__finalized', True) + tuple(PluginInfo(p) for p in plugins.itervalues()) + object.__setattr__(self, 'plugins', + tuple(PluginInfo(p) for p in plugins.itervalues()) + ) |