gpodder/src/gpodder/extensions.py

376 lines
12 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
#
# gPodder - A media aggregator and podcast client
# Copyright (c) 2005-2009 Thomas Perl and the gPodder Team
#
# gPodder 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; either version 3 of the License, or
# (at your option) any later version.
#
# gPodder 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, see <http://www.gnu.org/licenses/>.
"""
Loads and executes user extensions
Extensions are Python scripts in "$GPODDER_HOME/Extensions". Each script must
define a class named "gPodderExtension", otherwise it will be ignored.
The extensions class defines several callbacks that will be called by gPodder
at certain points. See the methods defined below for a list of callbacks and
their parameters.
For an example extension see examples/extensions.py
"""
import glob
import imp
import inspect
import json
import os
import functools
2012-02-05 17:16:20 +01:00
import shlex
import subprocess
import sys
import re
from datetime import datetime
import gpodder
_ = gpodder.gettext
from gpodder import util
import logging
logger = logging.getLogger(__name__)
def call_extensions(func):
"""Decorator to create handler functions in ExtensionManager
Calls the specified function in all user extensions that define it.
"""
method_name = func.__name__
@functools.wraps(func)
def handler(self, *args, **kwargs):
result = None
for container in self.containers:
if not container.enabled or container.module is None:
continue
try:
callback = getattr(container.module, method_name, None)
if callback is None:
continue
# If the results are lists, concatenate them to show all
# possible items that are generated by all extension together
cb_res = callback(*args, **kwargs)
if isinstance(result, list) and isinstance(cb_res, list):
result.extend(cb_res)
elif cb_res is not None:
result = cb_res
except Exception, exception:
logger.error('Error in %s in %s: %s', container.filename,
method_name, exception, exc_info=True)
func(self, *args, **kwargs)
return result
return handler
class ExtensionMetadata(object):
# Default fallback metadata in case metadata fields are missing
DEFAULTS = {
'description': _('No description for this extension.'),
}
def __init__(self, container, metadata):
if 'title' not in metadata:
metadata['title'] = container.name
self.__dict__.update(metadata)
def __getattr__(self, name):
try:
return self.DEFAULTS[name]
except KeyError:
raise AttributeError(name)
@property
def for_current_ui(self):
"""Check if this extension makes sense for the current UI
The __only_for__ metadata field in an extension can be a string with
comma-separated values for UIs. This will be checked against boolean
variables in the "gpodder.ui" object.
Example metadata field in an extension:
__only_for__ = 'gtk,qml'
In this case, this function will return True if any of the following
expressions will evaluate to True:
gpodder.ui.gtk
gpodder.ui.qml
New, unknown UIs are silently ignored and will evaluate to False.
"""
if not hasattr(self, 'only_for'):
return True
uis = filter(None, [x.strip() for x in self.only_for.split(',')])
return any(getattr(gpodder.ui, ui.lower(), False) for ui in uis)
class ExtensionContainer(object):
"""An extension container wraps one extension module"""
def __init__(self, manager, name, config, filename=None, module=None):
self.manager = manager
self.name = name
self.config = config
self.filename = filename
self.module = module
self.enabled = False
self.default_config = None
self.parameters = None
self.metadata = ExtensionMetadata(self, self._load_metadata(filename))
def _load_metadata(self, filename):
if not filename or not os.path.exists(filename):
return {}
extension_py = open(filename).read()
return dict(re.findall("__([a-z_]+)__ = '([^']+)'", extension_py))
def _load_module(self):
basename, extension = os.path.splitext(os.path.basename(self.filename))
fp = open(self.filename, 'r')
module = imp.load_module(basename, fp, self.filename,
(extension, 'r', imp.PY_SOURCE))
fp.close()
# Remove the .pyc file if it was created during import
util.delete_file(self.filename + 'c')
return module
def set_enabled(self, enabled):
if enabled:
try:
self.load_extension()
self.enabled = True
except Exception, exception:
logger.error('Cannot load %s from %s: %s', self.name,
self.filename, exception, exc_info=True)
self.enabled = False
else:
self.enabled = False
def load_extension(self):
"""Load and initialize the gPodder extension module"""
if self.module is not None:
logger.info('Module already loaded.')
return
if not self.metadata.for_current_ui:
logger.info('Not loading "%s" (only_for = "%s")',
self.name, self.metadata.only_for)
return
module_file = self._load_module()
self.default_config = getattr(module_file, 'DefaultConfig', {})
self.parameters = getattr(module_file, 'Parameters', {})
self.module = module_file.gPodderExtension(self)
logger.info('Module loaded: %s', self.filename)
class ExtensionManager(object):
"""Loads extensions and manages self-registering plugins"""
def __init__(self, core):
self.core = core
self.containers = []
core.config.add_observer(self._config_value_changed)
enabled_extensions = core.config.extensions.enabled
for name, filename in self._find_extensions():
logger.debug('Found extension "%s" in %s', name, filename)
config = getattr(core.config.extensions, name)
container = ExtensionContainer(self, name, config, filename)
if name in enabled_extensions:
container.set_enabled(True)
self.containers.append(container)
def _config_value_changed(self, name, old_value, new_value):
if name == 'extensions.enabled':
for container in self.containers:
new_enabled = (container.name in new_value)
if new_enabled != container.enabled:
logger.info('Extension "%s" is now %s', container.name,
'enabled' if new_enabled else 'disabled')
container.set_enabled(new_enabled)
def _find_extensions(self):
extensions = {}
root = os.path.abspath(gpodder.__path__[0]) # XXX: Works always?
builtins = os.path.join(root, 'builtins', '*.py')
user_extensions = os.path.join(gpodder.home, 'Extensions', '*.py')
# Let user extensions override built-in extensions of the same name
for filename in glob.glob(builtins) + glob.glob(user_extensions):
name, _ = os.path.splitext(os.path.basename(filename))
extensions[name] = filename
return sorted(extensions.items())
def get_extensions(self):
"""Get a list of all loaded extensions and their enabled flag"""
return self.containers
# Define all known handler functions here, decorate them with the
# "call_extension" decorator to forward all calls to extension scripts that have
# the same function defined in them. If the handler functions here contain
# any code, it will be called after all the extensions have been called.
@call_extensions
def on_ui_initialized(self, model, update_podcast_callback,
download_episode_callback):
"""Called when the user interface is initialized.
@param model: A gpodder.model.Model instance
@param update_podcast_callback: Function to update a podcast feed
@param download_episode_callback: Function to download an episode
"""
pass
@call_extensions
def on_podcast_subscribe(self, podcast):
"""Called when the user subscribes to a new podcast feed.
@param podcast: A gpodder.model.PodcastChannel instance
"""
pass
@call_extensions
def on_podcast_updated(self, podcast):
"""Called when a podcast feed was updated
This extension will be called even if there were no new episodes.
@param podcast: A gpodder.model.PodcastChannel instance
"""
pass
@call_extensions
def on_podcast_update_failed(self, podcast, exception):
"""Called when a podcast update failed.
@param podcast: A gpodder.model.PodcastChannel instance
@param exception: The reason.
"""
pass
@call_extensions
def on_podcast_save(self, podcast):
"""Called when a podcast is saved to the database
This extensions will be called when the user edits the metadata of
the podcast or when the feed was updated.
@param podcast: A gpodder.model.PodcastChannel instance
"""
pass
@call_extensions
def on_podcast_delete(self, podcast):
"""Called when a podcast is deleted from the database
@param podcast: A gpodder.model.PodcastChannel instance
"""
pass
@call_extensions
def on_episode_save(self, episode):
"""Called when an episode is saved to the database
This extension will be called when a new episode is added to the
database or when the state of an existing episode is changed.
@param episode: A gpodder.model.PodcastEpisode instance
"""
pass
@call_extensions
def on_episode_downloaded(self, episode):
"""Called when an episode has been downloaded
You can retrieve the filename via episode.local_filename(False)
@param episode: A gpodder.model.PodcastEpisode instance
"""
pass
@call_extensions
def on_all_episodes_downloaded(self):
"""Called when all episodes has been downloaded
"""
pass
@call_extensions
def on_episodes_context_menu(self, episodes):
"""Called when the episode list context menu is opened
You can add additional context menu entries here. You have to
return a list of tuples, where the first item is a label and
the second item is a callable that will get the episode as its
first and only parameter.
Example return value:
[('Mark as new', lambda episodes: ...)]
@param episodes: A list of gpodder.model.PodcastEpisode instances
"""
pass
@call_extensions
def on_episode_delete(self, episode, filename):
"""Called just before the episode's disk file is about to be
deleted."""
pass
@call_extensions
def on_episode_removed_from_podcast(self, episode):
"""Called just before the episode is about to be removed from
the podcast channel, e.g., when the episode has not been
downloaded and it disappears from the feed.
@param podcast: A gpodder.model.PodcastChannel instance
"""
pass
@call_extensions
def on_notification_show(self, title, message):
"""Called when a notification should be shown
@param title: title of the notification
@param message: message of the notification
"""
pass