flexget.plugin
Covered: 460 lines
Missed: 0 lines
Skipped 212 lines
Percent: 100 %
  1
""" Plugin Loading & Management.
  2
"""
  4
from flexget import plugins as plugins_pkg
  5
import sys
  6
import os
  7
import re
  8
import logging
  9
import time
 10
from requests import RequestException
 11
from event import add_event_handler as add_phase_handler
 13
log = logging.getLogger('plugin')
 15
__all__ = ['PluginWarning', 'PluginError', 'register_plugin', 'register_parser_option', 'register_feed_phase',
 16
           'get_plugin_by_name', 'get_plugins_by_group', 'get_plugin_keywords', 'get_plugins_by_phase',
 17
           'get_phases_by_plugin', 'internet', 'priority']
 20
class DependencyError(Exception):
 21
    """Plugin depends on other plugin, but it cannot be loaded.
 23
    Args:
 24
        issued_by: name of the plugin trying to do the import
 25
        missing: name of the plugin or library that is missing
 26
        message: user readable error message
 28
    All args are optional.
 29
    """
 31
    def __init__(self, issued_by=None, missing=None, message=None, silent=False):
 32
        super(DependencyError, self).__init__()
 33
        self.issued_by = issued_by
 34
        self.missing = missing
 35
        self._message = message
 36
        self.silent = silent
 38
    def _get_message(self):
 39
        if self._message:
 40
            return self._message
 41
        else:
 42
            return 'Plugin `%s` requires dependency `%s`' % (self.issued_by, self.missing)
 44
    def _set_message(self, message):
 45
        self._message = message
 47
    def has_message(self):
 48
        return self._message is not None
 50
    message = property(_get_message, _set_message)
 52
    def __str__(self):
 53
        return '<DependencyError(issued_by=%r,missing=%r,message=%r,silent=%r)>' % \
 54
            (self.issued_by, self.missing, self.message, self.silent)
 57
class RegisterException(Exception):
 59
    def __init__(self, value):
 60
        super(RegisterException, self).__init__()
 61
        self.value = value
 63
    def __str__(self):
 64
        return repr(self.value)
 67
class PluginWarning(Warning):
 69
    def __init__(self, value, logger=log, **kwargs):
 70
        super(PluginWarning, self).__init__()
 71
        self.value = value
 72
        self.log = logger
 73
        self.kwargs = kwargs
 75
    def __str__(self):
 76
        return unicode(self).encode('utf-8')
 78
    def __unicode__(self):
 79
        return self.value
 82
class PluginError(Exception):
 84
    def __init__(self, value, logger=log, **kwargs):
 85
        super(PluginError, self).__init__()
 86
        self.value = value
 87
        self.log = logger
 88
        self.kwargs = kwargs
 90
    def __str__(self):
 91
        return unicode(self).encode('utf-8')
 93
    def __unicode__(self):
 94
        return self.value
 97
class internet(object):
 98
    """@internet decorator for plugin phase methods.
100
    Catches all internet related exceptions and raises PluginError with relevant message.
101
    Feed handles PluginErrors by aborting the feed.
102
    """
104
    def __init__(self, logger=None):
105
        if logger:
106
            self.log = logger
107
        else:
108
            self.log = logging.getLogger('@internet')
110
    def __call__(self, func):
112
        def wrapped_func(*args, **kwargs):
113
            from httplib import BadStatusLine
114
            import urllib2
115
            try:
116
                return func(*args, **kwargs)
117
            except RequestException, e:
118
                log.debug('decorator caught RequestException')
119
                raise PluginError('RequestException: %s' % e)
120
            except urllib2.HTTPError, e:
121
                raise PluginError('HTTPError %s' % e.code, self.log)
122
            except urllib2.URLError, e:
123
                log.debug('decorator caught urlerror')
124
                raise PluginError('URLError %s' % e.reason, self.log)
125
            except BadStatusLine:
126
                log.debug('decorator caught badstatusline')
127
                raise PluginError('Got BadStatusLine', self.log)
128
            except ValueError, e:
129
                log.debug('decorator caught ValueError')
130
                raise PluginError(e.message)
131
            except IOError, e:
132
                log.debug('decorator caught ioerror')
133
                if hasattr(e, 'reason'):
134
                    raise PluginError('Failed to reach server. Reason: %s' % e.reason, self.log)
135
                elif hasattr(e, 'code'):
136
                    raise PluginError('The server couldn\'t fulfill the request. Error code: %s' % e.code, self.log)
137
                raise PluginError('IOError when connecting to server: %s' % e, self.log)
138
        return wrapped_func
141
def priority(value):
142
    """Priority decorator for phase methods"""
144
    def decorator(target):
145
        target.priority = value
146
        return target
147
    return decorator
150
def _strip_trailing_sep(path):
151
    return path.rstrip("\\/")
153
DEFAULT_PRIORITY = 128
157
feed_phases = ['start', 'input', 'metainfo', 'filter', 'download', 'modify', 'output', 'exit']
160
phase_methods = {
162
    'abort': 'on_feed_abort', # special; not a feed phase that gets called normally
165
    'accept': 'on_entry_accept',
166
    'reject': 'on_entry_reject',
167
    'fail': 'on_entry_fail',
170
    'process_start': 'on_process_start',
171
    'process_end': 'on_process_end',
172
}
173
phase_methods.update((_phase, 'on_feed_' + _phase) for _phase in feed_phases) # DRY
176
PLUGIN_NAMESPACE = 'flexget.plugins'
179
plugins = {}
182
plugins_loaded = False
184
_parser = None
185
_loaded_plugins = {}
186
_plugin_options = []
187
_new_phase_queue = {}
190
def register_parser_option(*args, **kwargs):
191
    """Adds a parser option to the global parser."""
192
    if _parser is None:
193
        import warnings
194
        warnings.warn('register_parser_option called before it can be')
195
        return
196
    _parser.add_option(*args, **kwargs)
197
    _plugin_options.append((args, kwargs))
200
def register_feed_phase(name, before=None, after=None):
201
    """Adds a new feed phase to the available phases."""
202
    if before and after:
203
        raise RegisterException('You can only give either before or after for a phase.')
204
    if not before and not after:
205
        raise RegisterException('You must specify either a before or after phase.')
206
    if name in feed_phases or name in _new_phase_queue:
207
        raise RegisterException('Phase %s already exists.' % name)
209
    def add_phase(phase_name, before, after):
210
        if not before is None and not before in feed_phases:
211
            return False
212
        if not after is None and not after in feed_phases:
213
            return False
215
        phase_methods[phase_name] = 'on_feed_' + phase_name
217
        if before is None:
218
            feed_phases.insert(feed_phases.index(after) + 1, phase_name)
219
        if after is None:
220
            feed_phases.insert(feed_phases.index(before), phase_name)
223
        for loaded_plugin in plugins:
224
            plugins[loaded_plugin].build_phase_handlers()
226
        return True
229
    if not add_phase(name, before, after):
230
        _new_phase_queue[name] = [before, after]
232
    for phase_name, args in _new_phase_queue.items():
233
        if add_phase(phase_name, *args):
234
            del _new_phase_queue[phase_name]
237
class Plugin(object):
238
    """
239
    Base class for auto-registering plugins.
241
    Note that inheriting form this class implies API version 2.
243
    .. warning::
245
       May be removed any time soon. Use :func:`register_plugin` instead until
246
       we decide API's destiny.
247
    """
248
    PLUGIN_INFO = dict(api_ver=2)
249
    LOGGER_NAME = None # use default name
251
    def __init__(self, plugin_info, *args, **kw):
252
        """Initialize basic plugin attributes."""
253
        self.plugin_info = plugin_info
254
        self.log = logging.getLogger(self.LOGGER_NAME or self.plugin_info.name)
257
class BuiltinPlugin(Plugin):
258
    """A builtin plugin."""
259
    PLUGIN_INFO = Plugin.PLUGIN_INFO.copy() # inherit base info
260
    PLUGIN_INFO.update(builtin=True)
263
class DebugPlugin(Plugin):
264
    """
265
        A plugin for debugging purposes.
266
    """
268
    PLUGIN_INFO = Plugin.PLUGIN_INFO.copy() # inherit base info
269
    PLUGIN_INFO.update(debug=True)
272
class PluginInfo(dict):
273
    """
274
    Allows accessing key/value pairs of this dictionary subclass via
275
    attributes. Also instantiates a plugin and initializes properties.
276
    """
278
    dupe_counter = 0
280
    @classmethod
281
    def name_from_class(cls, plugin_class):
282
        """Convention is to take camel-case class name and rewrite it to an underscore form, e.g. 'PluginName' to 'plugin_name'"""
283
        return re.sub('[A-Z]+', lambda i: '_' + i.group(0).lower(), plugin_class.__name__).lstrip('_')
285
    def __init__(self, plugin_class, name=None, groups=None, builtin=False, debug=False, api_ver=1):
286
        """
287
        Register a plugin.
289
        :plugin_class: The plugin factory.
290
        :name: Name of the plugin (if not given, default to factory class name in underscore form).
291
        :groups: Groups this plugin belongs to.
292
        :builtin: Auto-activated?
293
        :debug: True if plugin is for debugging purposes.
294
        :api_ver: Signature of callback hooks (1=feed; 2=feed,config).
295
        """
296
        dict.__init__(self)
298
        if groups is None:
299
            groups = []
300
        if name is None:
301
            name = PluginInfo.name_from_class(plugin_class)
304
        self.api_ver = api_ver
305
        self.name = name
306
        self.groups = groups
307
        self.builtin = builtin
308
        self.debug = debug
309
        self.phase_handlers = {}
312
        self.plugin_class = plugin_class
313
        if issubclass(self.plugin_class, Plugin):
315
            try:
316
                self.instance = self.plugin_class(self)
317
            except: # OK, gets re-raised
318
                log.error("Could not create plugin '%s' from class %s.%s" % (
319
                    self.name, self.plugin_class.__module__, self.plugin_class.__name__))
320
                raise
321
        else:
323
            self.instance = self.plugin_class()
324
            self.instance.plugin_info = self # give plugin easy access to its own info
325
            self.instance.log = logging.getLogger(getattr(self.instance, "LOGGER_NAME", None) or self.name)
327
        if self.name in plugins:
328
            PluginInfo.dupe_counter += 1
329
            log.critical('Error while registering plugin %s. %s' % \
330
                (self.name, ('A plugin with the name %s is already registered' % self.name)))
331
        else:
332
            self.build_phase_handlers()
333
            plugins[self.name] = self
335
    def reset_phase_handlers(self):
336
        """Temporary utility method"""
337
        self.phase_handlers = {}
338
        self.build_phase_handlers()
341
        raise NotImplementedError
343
    def build_phase_handlers(self):
344
        """(Re)build phase_handlers in this plugin"""
345
        for phase, method_name in phase_methods.iteritems():
346
            if phase in self.phase_handlers:
347
                continue
348
            if hasattr(self.instance, method_name):
349
                method = getattr(self.instance, method_name)
350
                if not callable(method):
351
                    continue
353
                if hasattr(method, 'priority'):
354
                    handler_prio = method.priority
355
                else:
356
                    handler_prio = DEFAULT_PRIORITY
357
                event = add_phase_handler('plugin.%s.%s' % (self.name, phase), method, handler_prio)
359
                event.plugin = self
360
                self.phase_handlers[phase] = event
362
    def __getattr__(self, attr):
363
        if attr in self:
364
            return self[attr]
365
        return dict.__getattribute__(self, attr)
367
    def __setattr__(self, attr, value):
368
        self[attr] = value
370
    def __str__(self):
371
        return '<PluginInfo(name=%s)>' % self.name
373
    __repr__ = __str__
376
register_plugin = PluginInfo
379
def register(plugin_class, groups=None, auto=False):
380
    """
381
    Register plugin with attributes according to C{PLUGIN_INFO} class variable.
382
    Additional groups can be optionally provided.
384
    :return: Plugin info of registered plugin.
385
    """
388
    if auto and plugin_class.__name__.endswith("PluginBase"):
389
        log.trace("NOT auto-registering plugin base class %s.%s" % (
390
            plugin_class.__module__, plugin_class.__name__))
391
        return
393
    info = plugin_class.PLUGIN_INFO
394
    name = PluginInfo.name_from_class(plugin_class)
397
    if name in plugins and plugin_class is plugins[name].plugin_class:
398
        if not auto:
399
            log.trace("Ignoring dupe registration of same class %s.%s" % (
400
                plugin_class.__module__, plugin_class.__name__))
401
        if groups:
402
            plugins[name].groups = list(set(groups) | set(plugins[name].groups))
403
        return plugins[name]
404
    else:
405
        if auto:
406
            log.trace("Auto-registering plugin %s" % name)
407
        return PluginInfo(plugin_class, name, list(set(info.get('groups', []) + (groups or []))),
408
            info.get('builtin', False), info.get('debug', False), info.get('api_ver', 1))
411
def get_standard_plugins_path():
412
    """Determine a plugin path suitable for general use."""
414
    env_path = os.environ.get('FLEXGET_PLUGIN_PATH')
415
    if env_path:
418
        path = map(_strip_trailing_sep, env_path.split(os.pathsep))
419
    else:
421
        path = [os.path.join(os.path.expanduser('~'), '.flexget', 'plugins')]
424
    path.append(os.path.abspath(os.path.dirname(plugins_pkg.__file__)))
443
    return path
446
def load_plugins_from_dirs(dirs):
447
    """
448
    :param list dirs: Directories from where plugins are loaded from
449
    """
452
    plugins_pkg.__path__ = map(_strip_trailing_sep, dirs)
455
    plugin_packages = []
456
    for dir in dirs:
457
        if not os.path.exists(dir):
458
            continue
459
        plugin_packages.extend([n for n in os.listdir(dir) if os.path.isdir(os.path.join(dir, n))])
462
    for subpkg in plugin_packages[:]:
464
        if os.path.isdir(os.path.join(os.path.dirname(plugins_pkg.__file__), subpkg)) and \
465
           os.path.isfile(os.path.join(os.path.dirname(plugins_pkg.__file__), subpkg, '__init__.py')):
468
            getattr(__import__(PLUGIN_NAMESPACE, fromlist=[subpkg], level=0), subpkg).__path__ = [
469
                _strip_trailing_sep(os.path.join(package_base, subpkg))
470
                for package_base in dirs if os.path.isfile(os.path.join(package_base, subpkg, '__init__.py'))]
471
        else:
472
            log.debug('removing defunct plugin_package %s' % subpkg)
473
            plugin_packages.remove(subpkg)
476
    for dir in dirs:
477
        if not dir:
478
            continue
479
        if os.path.isdir(dir):
480
            log.debug('Looking for plugins in %s', dir)
481
            load_plugins_from_dir(dir)
484
            for subpkg in plugin_packages:
485
                subpath = os.path.join(dir, subpkg)
486
                if os.path.isdir(subpath):
488
                    log.debug("Looking for sub-plugins in '%s'", subpath)
489
                    load_plugins_from_dir(dir, subpkg)
490
        else:
491
            log.debug('Ignoring non-existing plugin directory %s', dir)
494
def load_plugins_from_dir(basepath, subpkg=None):
504
    valid_suffixes = ['.py']
505
    namespace = PLUGIN_NAMESPACE + '.'
506
    dirpath = basepath
507
    if subpkg:
508
        namespace += subpkg + '.'
509
        dirpath = os.path.join(dirpath, subpkg)
511
    found_plugins = set()
512
    for filename in os.listdir(dirpath):
513
        path = os.path.join(dirpath, filename)
514
        if os.path.isfile(path):
515
            f_base, ext = os.path.splitext(filename)
516
            if ext in valid_suffixes:
517
                if f_base == '__init__':
518
                    continue # don't load __init__.py again
519
                if (namespace + f_base) in _loaded_plugins:
520
                    log.debug('Duplicate plugin module `%s` in `%s` ignored, `%s` already loaded!' % (
521
                        namespace + f_base, dirpath, _loaded_plugins[namespace + f_base]))
522
                else:
523
                    _loaded_plugins[namespace + f_base] = path
524
                    found_plugins.add(namespace + f_base)
526
    for modulename in found_plugins:
527
        try:
528
            __import__(modulename, level=0)
529
        except DependencyError, e:
530
            if e.has_message():
531
                msg = e.message
532
            else:
533
                msg = 'Plugin `%s` requires `%s` to load.' % (e.issued_by or modulename, e.missing or 'N/A')
534
            if not e.silent:
535
                log.warning(msg)
536
            else:
537
                log.debug(msg)
538
        except ImportError, e:
539
            log.critical('Plugin `%s` failed to import dependencies' % modulename)
540
            log.exception(e)
541
        except Exception, e:
542
            log.critical('Exception while loading plugin %s' % modulename)
543
            log.exception(e)
544
            raise
545
        else:
546
            log.trace('Loaded module %s from %s' % (modulename[len(PLUGIN_NAMESPACE) + 1:], dirpath))
550
            for obj in vars(sys.modules[modulename]).values():
551
                try:
552
                    if not issubclass(obj, Plugin):
553
                        continue
554
                except TypeError:
555
                    continue # not a class
556
                else:
557
                    register(obj, auto=True)
559
    if _new_phase_queue:
560
        for phase, args in _new_phase_queue.iteritems():
561
            log.error('Plugin %s requested new phase %s, but it could not be created at requested '
562
                      'point (before, after). Plugin is not working properly.' % (args[0], phase))
565
def load_plugins(parser):
566
    """Load plugins from the standard plugin paths."""
567
    global plugins_loaded, _parser
569
    if plugins_loaded:
570
        if parser is not None:
571
            for args, kwargs in _plugin_options:
572
                parser.add_option(*args, **kwargs)
573
        return 0
576
    import warnings
577
    warnings.simplefilter('ignore', DeprecationWarning)
579
    start_time = time.time()
580
    _parser = parser
581
    try:
582
        load_plugins_from_dirs(get_standard_plugins_path())
583
    finally:
584
        _parser = None
585
    took = time.time() - start_time
586
    plugins_loaded = True
587
    log.debug('Plugins took %.2f seconds to load' % took)
590
def get_plugins_by_phase(phase):
591
    """Return an iterator over all plugins that hook :phase:"""
592
    if not phase in phase_methods:
593
        raise Exception('Unknown phase %s' % phase)
594
    return (p for p in plugins.itervalues() if phase in p.phase_handlers)
597
def get_phases_by_plugin(name):
598
    """Return all phases plugin :name: hooks"""
599
    return list(get_plugin_by_name(name).phase_handlers)
602
def get_plugins_by_group(group):
603
    """Return an iterator over all plugins with in specified group."""
604
    return (p for p in plugins.itervalues() if group in p.get('groups'))
607
def get_plugin_keywords():
608
    """Return iterator over all plugin keywords."""
609
    return plugins.iterkeys()
612
def get_plugin_by_name(name, issued_by='???'):
613
    """Get plugin by name, preferred way since this structure may be changed at some point."""
614
    if not name in plugins:
615
        raise DependencyError(issued_by=issued_by, missing=name, message='Unknown plugin %s' % name)
616
    return plugins[name]
619
_excluded_recursively = []
622
def add_plugin_validators(validator, phase=None, group=None, excluded=None, api_ver=2):
623
    """
624
    :param validator: Instance of validator where other plugin validators are added into. (Eg. list or dict validator)
625
    :param phase: Name of phase which plugins are added.
626
    :param group: Name of group which plugins are added.
627
    :param excluded: List of plugin names to excluded.
628
    :param api_ver: Add only api_ver plugins (defaults to 2)
629
    :returns: dict validator wrapping the newly added validators
630
    """
637
    if excluded is None:
638
        excluded = []
640
    _excluded_recursively.extend(excluded)
643
    valid_plugins = []
644
    if phase is not None:
645
        if not isinstance(phase, basestring):
646
            raise ValueError('Invalid phase `%s`' % repr(phase))
647
        valid_plugins.extend([plugin for plugin in get_plugins_by_phase(phase)
648
                              if plugin.api_ver == api_ver and plugin.name not in _excluded_recursively])
650
    if group is not None:
651
        if not isinstance(group, basestring):
652
            raise ValueError('Invalid group `%s`' % repr(group))
653
        valid_plugins.extend([plugin for plugin in get_plugins_by_group(group)
654
                              if plugin.name not in _excluded_recursively])
659
    outer_validator = validator.accept('dict')
661
    for plugin in valid_plugins:
663
        if hasattr(plugin.instance, 'validator'):
664
            outer_validator.accept(plugin.instance.validator, key=plugin.name)
665
        else:
666
            outer_validator.accept('any', key=plugin.name)
668
    for name in excluded:
669
        _excluded_recursively.remove(name)
671
    return validator