a few small bugfixes for the extension system
[gpodder.git] / src / gpodder / extensions.py
blob23a0c9c19d4ae9c0a27fd923153fe85a99b19f26
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2009 Thomas Perl and the gPodder Team
6 # gPodder is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # gPodder is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 """
20 Loads and executes user extensions
22 Extensions are Python scripts in "$GPODDER_HOME/Extensions". Each script must
23 define a class named "gPodderExtensions", otherwise it will be ignored.
25 The extensions class defines several callbacks that will be called by gPodder
26 at certain points. See the methods defined below for a list of callbacks and
27 their parameters.
29 For an example extension see examples/extensions.py
30 """
32 import glob
33 import imp
34 import inspect
35 import json
36 import os
37 import functools
38 import shlex
39 import subprocess
40 import sys
41 import re
42 from datetime import datetime
44 import gpodder
45 from gpodder import util
46 from gpodder.jsonconfig import JsonConfig
48 import logging
49 logger = logging.getLogger(__name__)
51 # The class name that has to appear in a extension module
52 EXTENSION_CLASS = 'gPodderExtension'
54 # The variable name that stores the extensions parameters
55 EXTENSION_PARAMS = 'PARAMS'
57 # The variable name that stores the extensions parameters
58 EXTENSION_CONFIG = 'DEFAULT_CONFIG'
60 # The variable name the directory where the extensions are stored
61 EXTENSION_FOLDER = 'gpodder_extensions'
64 def call_extensions(func):
65 """Decorator to create handler functions in ExtensionManager
67 Calls the specified function in all user extensions that define it.
68 """
69 method_name = func.__name__
71 @functools.wraps(func)
72 def handler(self, *args, **kwargs):
73 result = None
74 for extension_container, state in self.modules:
75 if state:
76 try:
77 callback = getattr(extension_container.module, method_name, None)
78 if callback is not None:
79 # If the results are lists, concatenate them to show all
80 # possible items that are generated by all extension together
81 cb_res = callback(*args, **kwargs)
82 if isinstance(result, list) and isinstance(cb_res, list):
83 result.extend(cb_res)
84 elif cb_res is not None:
85 result = cb_res
86 except Exception, e:
87 logger.error('Error in %s, function %s: %s', extension_container.extension_file,
88 method_name, e, exc_info=True)
89 func(self, *args, **kwargs)
90 return result
92 return handler
95 class ExtensionParent(object):
96 """Super class for every extension"""
98 def __init__(self, **kwargs):
99 self.metadata = kwargs.get('metadata', None)
100 self.config = kwargs.get('config', None)
101 self.context_menu_callback = None
102 self.read_metadata()
104 # this code is needed when running the extensions unittests
105 if isinstance(self.config, dict) and self.metadata:
106 self.config = JsonConfig(data=json.dumps(self.config))
107 self.config = getattr(self.config.extensions, self.metadata['id'])
109 def read_metadata(self):
110 self.id = None
111 self.name = None
112 self.desc = None
114 if self.metadata is not None:
115 self.id = self.metadata.get('id', None)
116 self.name = self.metadata.get('name', None)
117 self.desc = self.metadata.get('desc', None)
119 def check_command(self, cmd):
120 """Check if a command line command/program exists"""
122 # Prior to Python 2.7.3, this module (shlex) did not support Unicode input.
123 cmd = util.sanitize_encoding(cmd)
124 program = shlex.split(cmd)[0]
125 if util.find_command(program) is None:
126 raise ImportError("Couldn't find program '%s'" % program)
128 def notify_action(self, action, episode):
129 """method to simple use the notification system"""
131 if self.config is None or not self.config.enable_notifications:
132 return
134 name = 'gPodder-Extension'
135 if self.name is not None:
136 name = '%s: %s' % (name, self.name)
138 now = datetime.now()
139 msg = "%s(%s): '%s/%s'" % (action, now.strftime('%x %X'),
140 episode.channel.title, episode.title)
142 gpodder.user_extensions.on_notification_show(name, msg)
144 def _show_context_menu(self, episodes):
145 """return if a context menu entry should be displayed"""
146 return False
148 def on_episodes_context_menu(self, episodes):
149 """add context menu entry for a specific extension"""
151 if self.name is None:
152 return None
154 if not self._show_context_menu(episodes) or self.context_menu_callback is None:
155 return None
157 return [(self.name, self.context_menu_callback)]
159 def rename_episode_file(self, episode, filename):
160 """method for simple update an episode with filename information"""
162 if not os.path.exists(filename):
163 return
165 basename, extension = os.path.splitext(filename)
167 episode.download_filename = os.path.basename(filename)
168 episode.file_size = os.path.getsize(filename)
169 episode.mime_type = util.mimetype_from_extension(extension)
170 episode.save()
171 episode.db.commit()
173 def get_filename(self, episode):
174 filename = episode.local_filename(create=False, check_only=True)
175 if filename is not None and os.path.exists(filename):
176 return filename
178 return None
181 class ExtensionContainer(object):
182 """A class which manage one extension"""
184 def __init__(self, config=None, filename=None, module=None):
185 self.extension_file = filename
186 self.module = module
187 self._gpo_config = config
188 self.config = None
189 self.params = None
190 self.metadata = None
192 if filename is not None and module is None:
193 self.metadata = self._load_metadata(filename)
194 elif filename is None and module is not None:
195 pass
196 else:
197 logger.error("ExtensionContainer couldn't initialize successfully")
199 def _load_module(self, filename):
200 basename, extension = os.path.splitext(os.path.basename(filename))
201 return imp.load_module(basename, file(filename, 'r'),
202 filename, (extension, 'r', imp.PY_SOURCE))
204 def _load_metadata(self, filename):
205 extension_py = open(filename).read()
206 return dict(re.findall("__([a-z]+)__ = '([^']+)'", extension_py))
208 def _load_user_prefs(self, module_file):
209 if not self.metadata['id'] in self._gpo_config.extensions.keys():
210 config = getattr(module_file, EXTENSION_CONFIG, None)
211 if config is not None:
212 self._gpo_config.register_defaults(config)
214 return getattr(self._gpo_config.extensions, self.metadata['id'])
216 def load_extension(self):
217 """Load a Python module by filename
219 Returns an instance of the EXTENSION_CLASS class defined
220 in the module, or None if the module does not contain
221 such a class.
223 try:
224 module_file = self._load_module(self.extension_file)
225 self.config = self._load_user_prefs(module_file)
226 self.params = getattr(module_file, EXTENSION_PARAMS, None)
228 extension_class = getattr(module_file, EXTENSION_CLASS, None)
229 self.module = extension_class(
230 metadata=self.metadata,
231 config=self.config
234 logger.info('Module loaded: %s', self.extension_file)
235 except Exception, e:
236 logger.error('Cannot load %s: %s', self.extension_file, e, exc_info=True)
237 raise
239 def revert_settings(self):
240 """revert stored extension settings to the default values"""
242 if self.metadata['id'] in self._gpo_config.extensions.keys():
243 del self._gpo_config.extensions[self.metadata['id']]
245 module_file = self._load_module(self.extension_file)
247 config = getattr(module_file, EXTENSION_CONFIG, None)
248 if config is not None:
249 self._gpo_config.register_defaults(config)
250 self._gpo_config.save()
253 class ExtensionManager(object):
254 """Manager class for the extensions
256 This class loads all available extensions from the filesystem
257 it also holds all "extension"-methods which can be used in an
258 extension
261 DISABLED, ENABLED = range(2)
262 EXTENSIONCONTAINER, STATE = range(2)
264 def __init__(self, config):
265 self.modules = []
266 self._config = config
267 enabled_extensions = self._config.extensions.enabled
269 pathname = os.path.join(os.path.abspath(gpodder.__path__[0]),
270 EXTENSION_FOLDER, '*.py')
271 for extension_file in glob.glob(pathname):
272 extension_container = ExtensionContainer(
273 config=self._config, filename=extension_file)
275 state = self.DISABLED
276 if extension_container.metadata:
277 extension_id = extension_container.metadata['id']
278 if extension_id in enabled_extensions:
279 error = extension_container.load_extension()
280 if error is None:
281 state = self.ENABLED
282 else:
283 state = self.DISABLED
284 enabled_extensions.remove(extension_id)
286 self.modules.append((extension_container, state, ))
288 def register_extension(self, obj):
289 """Register an object that implements some extensions."""
291 self.modules.append((ExtensionContainer(module=obj), self.ENABLED))
293 def unregister_extension(self, obj):
294 """Unregister a previously registered object."""
296 extension_module = (ExtensionContainer(module=obj), self.ENABLED)
297 if extension_module in self.modules:
298 self.modules.remove(extension_module)
299 else:
300 logger.warn('Unregistered extension which was not registered.')
302 def get_extensions(self):
303 """returns a list of all loaded extensions with the enabled/disable state"""
305 enabled_extensions = self._config.extensions.enabled
306 for index, (extension_container, enabled) in enumerate(self.modules):
307 if extension_container.metadata:
308 if extension_container.metadata['id'] in enabled_extensions:
309 enabled = self.ENABLED
310 else:
311 enabled = self.DISABLED
312 self.modules[index] = (extension_container, enabled, )
314 return self.modules
316 # Define all known handler functions here, decorate them with the
317 # "call_extension" decorator to forward all calls to extension scripts that have
318 # the same function defined in them. If the handler functions here contain
319 # any code, it will be called after all the extensions have been called.
321 @call_extensions
322 def on_ui_initialized(self, model, update_podcast_callback,
323 download_episode_callback):
324 """Called when the user interface is initialized.
326 @param model: A gpodder.model.Model instance
327 @param update_podcast_callback: Function to update a podcast feed
328 @param download_episode_callback: Function to download an episode
330 pass
332 @call_extensions
333 def on_podcast_subscribe(self, podcast):
334 """Called when the user subscribes to a new podcast feed.
336 @param podcast: A gpodder.model.PodcastChannel instance
338 pass
340 @call_extensions
341 def on_podcast_updated(self, podcast):
342 """Called when a podcast feed was updated
344 This extension will be called even if there were no new episodes.
346 @param podcast: A gpodder.model.PodcastChannel instance
348 pass
350 @call_extensions
351 def on_podcast_update_failed(self, podcast, exception):
352 """Called when a podcast update failed.
354 @param podcast: A gpodder.model.PodcastChannel instance
356 @param exception: The reason.
358 pass
360 @call_extensions
361 def on_podcast_save(self, podcast):
362 """Called when a podcast is saved to the database
364 This extensions will be called when the user edits the metadata of
365 the podcast or when the feed was updated.
367 @param podcast: A gpodder.model.PodcastChannel instance
369 pass
371 @call_extensions
372 def on_podcast_delete(self, podcast):
373 """Called when a podcast is deleted from the database
375 @param podcast: A gpodder.model.PodcastChannel instance
377 pass
379 @call_extensions
380 def on_episode_save(self, episode):
381 """Called when an episode is saved to the database
383 This extension will be called when a new episode is added to the
384 database or when the state of an existing episode is changed.
386 @param episode: A gpodder.model.PodcastEpisode instance
388 pass
390 @call_extensions
391 def on_episode_downloaded(self, episode):
392 """Called when an episode has been downloaded
394 You can retrieve the filename via episode.local_filename(False)
396 @param episode: A gpodder.model.PodcastEpisode instance
398 pass
400 @call_extensions
401 def on_all_episodes_downloaded(self):
402 """Called when all episodes has been downloaded
404 pass
406 @call_extensions
407 def on_episodes_context_menu(self, episodes):
408 """Called when the episode list context menu is opened
410 You can add additional context menu entries here. You have to
411 return a list of tuples, where the first item is a label and
412 the second item is a callable that will get the episode as its
413 first and only parameter.
415 Example return value:
417 [('Mark as new', lambda episodes: ...)]
419 @param episodes: A list of gpodder.model.PodcastEpisode instances
421 pass
423 @call_extensions
424 def on_episode_delete(self, episode, filename):
425 """Called just before the episode's disk file is about to be
426 deleted."""
427 pass
429 @call_extensions
430 def on_episode_removed_from_podcast(self, episode):
431 """Called just before the episode is about to be removed from
432 the podcast channel, e.g., when the episode has not been
433 downloaded and it disappears from the feed.
435 @param podcast: A gpodder.model.PodcastChannel instance
437 pass
439 @call_extensions
440 def on_notification_show(self, title, message):
441 """Called when a notification should be shown
443 @param title: title of the notification
444 @param message: message of the notification
446 pass