1
""" Plugin Loading & Management.
4
from flexget import plugins as plugins_pkg
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.
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.
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
38
def _get_message(self):
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)
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__()
64
return repr(self.value)
67
class PluginWarning(Warning):
69
def __init__(self, value, logger=log, **kwargs):
70
super(PluginWarning, self).__init__()
76
return unicode(self).encode('utf-8')
78
def __unicode__(self):
82
class PluginError(Exception):
84
def __init__(self, value, logger=log, **kwargs):
85
super(PluginError, self).__init__()
91
return unicode(self).encode('utf-8')
93
def __unicode__(self):
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.
104
def __init__(self, logger=None):
108
self.log = logging.getLogger('@internet')
110
def __call__(self, func):
112
def wrapped_func(*args, **kwargs):
113
from httplib import BadStatusLine
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)
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)
142
"""Priority decorator for phase methods"""
144
def decorator(target):
145
target.priority = value
150
def _strip_trailing_sep(path):
151
return path.rstrip("\\/")
153
DEFAULT_PRIORITY = 128
155
# feed phases, in order of their execution; note that this can be extended by
156
# registering new phases at runtime
157
feed_phases = ['start', 'input', 'metainfo', 'filter', 'download', 'modify', 'output', 'exit']
159
# map phase names to method names
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',
173
phase_methods.update((_phase, 'on_feed_' + _phase) for _phase in feed_phases) # DRY
175
# Plugin package naming
176
PLUGIN_NAMESPACE = 'flexget.plugins'
178
# Mapping of plugin name to PluginInfo instance (logical singletons)
182
plugins_loaded = False
190
def register_parser_option(*args, **kwargs):
191
"""Adds a parser option to the global parser."""
194
warnings.warn('register_parser_option called before it can be')
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."""
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:
212
if not after is None and not after in feed_phases:
214
# add method name to phase -> method lookup table
215
phase_methods[phase_name] = 'on_feed_' + phase_name
216
# place phase in phase list
218
feed_phases.insert(feed_phases.index(after) + 1, phase_name)
220
feed_phases.insert(feed_phases.index(before), phase_name)
222
# create possibly newly available phase handlers
223
for loaded_plugin in plugins:
224
plugins[loaded_plugin].build_phase_handlers()
228
# if can't add yet (dependencies) queue addition
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]
239
Base class for auto-registering plugins.
241
Note that inheriting form this class implies API version 2.
245
May be removed any time soon. Use :func:`register_plugin` instead until
246
we decide API's destiny.
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):
265
A plugin for debugging purposes.
267
# Note that debug plugins are never builtin, so we don't need a mixin
268
PLUGIN_INFO = Plugin.PLUGIN_INFO.copy() # inherit base info
269
PLUGIN_INFO.update(debug=True)
272
class PluginInfo(dict):
274
Allows accessing key/value pairs of this dictionary subclass via
275
attributes. Also instantiates a plugin and initializes properties.
277
# Counts duplicate registrations
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):
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).
301
name = PluginInfo.name_from_class(plugin_class)
303
# Set basic info attributes
304
self.api_ver = api_ver
307
self.builtin = builtin
309
self.phase_handlers = {}
311
# Create plugin instance
312
self.plugin_class = plugin_class
313
if issubclass(self.plugin_class, Plugin):
314
# Base class init needs plugin info immediately
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__))
322
# Manually registered
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)))
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()
339
# TODO: should unregister events (from flexget.event)
340
# this method is not used at the moment anywhere ...
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:
348
if hasattr(self.instance, method_name):
349
method = getattr(self.instance, method_name)
350
if not callable(method):
352
# check for priority decorator
353
if hasattr(method, 'priority'):
354
handler_prio = method.priority
356
handler_prio = DEFAULT_PRIORITY
357
event = add_phase_handler('plugin.%s.%s' % (self.name, phase), method, handler_prio)
358
# provides backwards compatibility
360
self.phase_handlers[phase] = event
362
def __getattr__(self, attr):
365
return dict.__getattribute__(self, attr)
367
def __setattr__(self, attr, value):
371
return '<PluginInfo(name=%s)>' % self.name
376
register_plugin = PluginInfo
379
def register(plugin_class, groups=None, auto=False):
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.
386
# Base classes outside of plugin modules are NEVER auto-registered; if you have ones
387
# in a plugin module, use the "*PluginBase" naming convention
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__))
393
info = plugin_class.PLUGIN_INFO
394
name = PluginInfo.name_from_class(plugin_class)
396
# If this very class was already registered, that's OK
397
if name in plugins and plugin_class is plugins[name].plugin_class:
399
log.trace("Ignoring dupe registration of same class %s.%s" % (
400
plugin_class.__module__, plugin_class.__name__))
402
plugins[name].groups = list(set(groups) | set(plugins[name].groups))
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."""
413
# Get basic path from enironment
414
env_path = os.environ.get('FLEXGET_PLUGIN_PATH')
416
# Get rid of trailing slashes, since Python can't handle them when
417
# it tries to import modules.
418
path = map(_strip_trailing_sep, env_path.split(os.pathsep))
420
# Use standard default
421
path = [os.path.join(os.path.expanduser('~'), '.flexget', 'plugins')]
423
# Add flexget.plugins directory (core plugins)
424
path.append(os.path.abspath(os.path.dirname(plugins_pkg.__file__)))
426
# Leads to problems if flexget is imported via PYTHONPATH and another copy
427
# is installed into site-packages, remove this altogether if nobody has any problems
428
# (and those that have can use FLEXGET_PLUGIN_PATH explicitely)
429
## Search the arch independent path if we can determine that and
430
## the plugin is found nowhere else, and we're in default mode
431
#if not env_path and sys.platform != 'win32':
433
# from distutils.sysconfig import get_python_lib
434
# except ImportError:
435
# # If distutuils is not available, we just won't add that path
436
# log.debug('FYI: Not adding archless plugin path due to distutils missing')
438
# archless_path = os.path.join(get_python_lib(), 'flexget', 'plugins')
439
# if archless_path not in path:
440
# log.debug("FYI: Adding archless plugin path '%s' from distutils" % archless_path)
441
# path.append(archless_path)
446
def load_plugins_from_dirs(dirs):
448
:param list dirs: Directories from where plugins are loaded from
451
# add all dirs to plugins_pkg load path so that plugins are loaded from flexget and from ~/.flexget/plugins/
452
plugins_pkg.__path__ = map(_strip_trailing_sep, dirs)
454
# construct list of plugin_packages from all load path subdirectories
457
if not os.path.exists(dir):
459
plugin_packages.extend([n for n in os.listdir(dir) if os.path.isdir(os.path.join(dir, n))])
461
# import plugin packages and manipulate their load paths
462
for subpkg in plugin_packages[:]:
463
# must be a proper python package with __init__.py
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')):
467
# import sub-package and manipulate its search path so that all existing subdirs are in it
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'))]
472
log.debug('removing defunct plugin_package %s' % subpkg)
473
plugin_packages.remove(subpkg)
475
# actually load plugins from directories
479
if os.path.isdir(dir):
480
log.debug('Looking for plugins in %s', dir)
481
load_plugins_from_dir(dir)
483
# Also look in sub-packages named like the feed phases, plus "generic"
484
for subpkg in plugin_packages:
485
subpath = os.path.join(dir, subpkg)
486
if os.path.isdir(subpath):
487
# Only log existing subdirs
488
log.debug("Looking for sub-plugins in '%s'", subpath)
489
load_plugins_from_dir(dir, subpkg)
491
log.debug('Ignoring non-existing plugin directory %s', dir)
494
def load_plugins_from_dir(basepath, subpkg=None):
495
# Get the list of valid python suffixes for plugins
496
# this includes .py, .pyc, and .pyo (depending on if we are running -O)
497
# but it doesn't include compiled modules (.so, .dll, etc)
499
# This causes quite a bit problems when renaming plugins and is there really need to import .pyc / pyo?
502
# valid_suffixes = [suffix for suffix, mod_type, flags in imp.get_suffixes()
503
# if flags in (imp.PY_SOURCE, imp.PY_COMPILED)]
504
valid_suffixes = ['.py']
505
namespace = PLUGIN_NAMESPACE + '.'
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]))
523
_loaded_plugins[namespace + f_base] = path
524
found_plugins.add(namespace + f_base)
526
for modulename in found_plugins:
528
__import__(modulename, level=0)
529
except DependencyError, e:
533
msg = 'Plugin `%s` requires `%s` to load.' % (e.issued_by or modulename, e.missing or 'N/A')
538
except ImportError, e:
539
log.critical('Plugin `%s` failed to import dependencies' % modulename)
542
log.critical('Exception while loading plugin %s' % modulename)
546
log.trace('Loaded module %s from %s' % (modulename[len(PLUGIN_NAMESPACE) + 1:], dirpath))
548
# Auto-register plugins that inherit from plugin base classes,
549
# and weren't already registered manually
550
for obj in vars(sys.modules[modulename]).values():
552
if not issubclass(obj, Plugin):
555
continue # not a class
557
register(obj, auto=True)
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
570
if parser is not None:
571
for args, kwargs in _plugin_options:
572
parser.add_option(*args, **kwargs)
575
# suppress DeprecationWarning's
577
warnings.simplefilter('ignore', DeprecationWarning)
579
start_time = time.time()
582
load_plugins_from_dirs(get_standard_plugins_path())
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)
619
_excluded_recursively = []
622
def add_plugin_validators(validator, phase=None, group=None, excluded=None, api_ver=2):
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
633
# I'm not entirely happy how this turned out, since plugins are usually configured in dict context
634
# we must use dict wrapper in there (outer_validator), making this somewhat unsuitable for cases where
635
# list should just accept list of plugins (see discover plugin)
640
_excluded_recursively.extend(excluded)
642
# Get a list of 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])
656
# log.debug('valid_plugins: %s' % [plugin.name for plugin in valid_plugins])
657
# log.debug('_excluded_recursively: %s' % _excluded_recursively)
659
outer_validator = validator.accept('dict')
660
# Build a dict validator that accepts the available input plugins and their settings
661
for plugin in valid_plugins:
662
# log.debug('adding: %s' % plugin.name)
663
if hasattr(plugin.instance, 'validator'):
664
outer_validator.accept(plugin.instance.validator, key=plugin.name)
666
outer_validator.accept('any', key=plugin.name)
668
for name in excluded:
669
_excluded_recursively.remove(name)