1# -*- coding: utf-8 -*-
2#
3# gPodder - A media aggregator and podcast client
4# Copyright (c) 2005-2009 Thomas Perl and the gPodder Team
5#
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.
10#
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.
15#
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/>.
18
19"""
20Loads and executes user extensions
21
22Extensions are Python scripts in "$GPODDER_HOME/Extensions". Each script must
23define a class named "gPodderExtension", otherwise it will be ignored.
24
25The extensions class defines several callbacks that will be called by gPodder
26at certain points. See the methods defined below for a list of callbacks and
27their parameters.
28
29For an example extension see share/gpodder/examples/extensions.py
30"""
31
32import functools
33import glob
34import imp
35import inspect
36import json
37import logging
38import os
39import re
40import shlex
41import subprocess
42import sys
43from datetime import datetime
44
45import gpodder
46from gpodder import util
47
48_ = gpodder.gettext
49
50
51logger = logging.getLogger(__name__)
52
53
54CATEGORY_DICT = {
55    'desktop-integration': _('Desktop Integration'),
56    'interface': _('Interface'),
57    'post-download': _('Post download'),
58}
59DEFAULT_CATEGORY = _('Other')
60
61
62def call_extensions(func):
63    """Decorator to create handler functions in ExtensionManager
64
65    Calls the specified function in all user extensions that define it.
66    """
67    method_name = func.__name__
68
69    @functools.wraps(func)
70    def handler(self, *args, **kwargs):
71        result = None
72        for container in self.containers:
73            if not container.enabled or container.module is None:
74                continue
75
76            try:
77                callback = getattr(container.module, method_name, None)
78                if callback is None:
79                    continue
80
81                # If the results are lists, concatenate them to show all
82                # possible items that are generated by all extension together
83                cb_res = callback(*args, **kwargs)
84                if isinstance(result, list) and isinstance(cb_res, list):
85                    result.extend(cb_res)
86                elif cb_res is not None:
87                    result = cb_res
88            except Exception as exception:
89                logger.error('Error in %s in %s: %s', container.filename,
90                        method_name, exception, exc_info=True)
91        func(self, *args, **kwargs)
92        return result
93
94    return handler
95
96
97class ExtensionMetadata(object):
98    # Default fallback metadata in case metadata fields are missing
99    DEFAULTS = {
100        'description': _('No description for this extension.'),
101        'doc': None,
102        'payment': None,
103    }
104    SORTKEYS = {
105        'title': 1,
106        'description': 2,
107        'category': 3,
108        'authors': 4,
109        'only_for': 5,
110        'mandatory_in': 6,
111        'disable_in': 7,
112    }
113
114    def __init__(self, container, metadata):
115        if 'title' not in metadata:
116            metadata['title'] = container.name
117
118        category = metadata.get('category', 'other')
119        metadata['category'] = CATEGORY_DICT.get(category, DEFAULT_CATEGORY)
120
121        self.__dict__.update(metadata)
122
123    def __getattr__(self, name):
124        try:
125            return self.DEFAULTS[name]
126        except KeyError as e:
127            raise AttributeError(name, e)
128
129    def get_sorted(self):
130
131        def kf(x):
132            return self.SORTKEYS.get(x[0], 99)
133
134        return sorted([(k, v) for k, v in list(self.__dict__.items())], key=kf)
135
136    def check_ui(self, target, default):
137        """Checks metadata information like
138            __only_for__ = 'gtk'
139            __mandatory_in__ = 'gtk'
140            __disable_in__ = 'gtk'
141
142        The metadata fields in an extension can be a string with
143        comma-separated values for UIs. This will be checked against
144        boolean variables in the "gpodder.ui" object.
145
146        Example metadata field in an extension:
147
148            __only_for__ = 'gtk'
149            __only_for__ = 'unity'
150
151        In this case, this function will return the value of the default
152        if any of the following expressions will evaluate to True:
153
154            gpodder.ui.gtk
155            gpodder.ui.unity
156            gpodder.ui.cli
157            gpodder.ui.osx
158            gpodder.ui.win32
159
160        New, unknown UIs are silently ignored and will evaluate to False.
161        """
162        if not hasattr(self, target):
163            return default
164
165        uis = [_f for _f in [x.strip() for x in getattr(self, target).split(',')] if _f]
166        return any(getattr(gpodder.ui, ui.lower(), False) for ui in uis)
167
168    @property
169    def available_for_current_ui(self):
170        return self.check_ui('only_for', True)
171
172    @property
173    def mandatory_in_current_ui(self):
174        return self.check_ui('mandatory_in', False)
175
176    @property
177    def disable_in_current_ui(self):
178        return self.check_ui('disable_in', False)
179
180
181class MissingDependency(Exception):
182    def __init__(self, message, dependency, cause=None):
183        Exception.__init__(self, message)
184        self.dependency = dependency
185        self.cause = cause
186
187
188class MissingModule(MissingDependency): pass
189
190
191class MissingCommand(MissingDependency): pass
192
193
194class ExtensionContainer(object):
195    """An extension container wraps one extension module"""
196
197    def __init__(self, manager, name, config, filename=None, module=None):
198        self.manager = manager
199
200        self.name = name
201        self.config = config
202        self.filename = filename
203        self.module = module
204        self.enabled = False
205        self.error = None
206
207        self.default_config = None
208        self.parameters = None
209        self.metadata = ExtensionMetadata(self, self._load_metadata(filename))
210
211    def require_command(self, command):
212        """Checks if the given command is installed on the system
213
214        Returns the complete path of the command
215
216        @param command: String with the command name
217        """
218        result = util.find_command(command)
219        if result is None:
220            msg = _('Command not found: %(command)s') % {'command': command}
221            raise MissingCommand(msg, command)
222        return result
223
224    def require_any_command(self, command_list):
225        """Checks if any of the given commands is installed on the system
226
227        Returns the complete path of first found command in the list
228
229        @param command: List with the commands name
230        """
231        for command in command_list:
232            result = util.find_command(command)
233            if result is not None:
234                return result
235
236        msg = _('Need at least one of the following commands: %(list_of_commands)s') % \
237            {'list_of_commands': ', '.join(command_list)}
238        raise MissingCommand(msg, ', '.join(command_list))
239
240    def _load_metadata(self, filename):
241        if not filename or not os.path.exists(filename):
242            return {}
243
244        encoding = util.guess_encoding(filename)
245        extension_py = open(filename, "r", encoding=encoding).read()
246        metadata = dict(re.findall("__([a-z_]+)__ = '([^']+)'", extension_py))
247
248        # Support for using gpodder.gettext() as _ to localize text
249        localized_metadata = dict(re.findall("__([a-z_]+)__ = _\('([^']+)'\)",
250            extension_py))
251
252        for key in localized_metadata:
253            metadata[key] = gpodder.gettext(localized_metadata[key])
254
255        return metadata
256
257    def set_enabled(self, enabled):
258        if enabled and not self.enabled:
259            try:
260                self.load_extension()
261                self.error = None
262                self.enabled = True
263                if hasattr(self.module, 'on_load'):
264                    self.module.on_load()
265            except Exception as exception:
266                logger.error('Cannot load %s from %s: %s', self.name,
267                        self.filename, exception, exc_info=True)
268                if isinstance(exception, ImportError):
269                    # Wrap ImportError in MissingCommand for user-friendly
270                    # message (might be displayed in the GUI)
271                    if exception.name:
272                        module = exception.name
273                        msg = _('Python module not found: %(module)s') % {
274                            'module': module
275                        }
276                        exception = MissingCommand(msg, module, exception)
277                self.error = exception
278                self.enabled = False
279        elif not enabled and self.enabled:
280            try:
281                if hasattr(self.module, 'on_unload'):
282                    self.module.on_unload()
283            except Exception as exception:
284                logger.error('Failed to on_unload %s: %s', self.name,
285                        exception, exc_info=True)
286            self.enabled = False
287
288    def load_extension(self):
289        """Load and initialize the gPodder extension module"""
290        if self.module is not None:
291            logger.info('Module already loaded.')
292            return
293
294        if not self.metadata.available_for_current_ui:
295            logger.info('Not loading "%s" (only_for = "%s")',
296                    self.name, self.metadata.only_for)
297            return
298
299        basename, extension = os.path.splitext(os.path.basename(self.filename))
300        fp = open(self.filename, 'r')
301        try:
302            module_file = imp.load_module(basename, fp, self.filename,
303                    (extension, 'r', imp.PY_SOURCE))
304        finally:
305            # Remove the .pyc file if it was created during import
306            util.delete_file(self.filename + 'c')
307        fp.close()
308
309        self.default_config = getattr(module_file, 'DefaultConfig', {})
310        if self.default_config:
311            self.manager.core.config.register_defaults({
312                'extensions': {
313                    self.name: self.default_config,
314                }
315            })
316        self.config = getattr(self.manager.core.config.extensions, self.name)
317
318        self.module = module_file.gPodderExtension(self)
319        logger.info('Module loaded: %s', self.filename)
320
321
322class ExtensionManager(object):
323    """Loads extensions and manages self-registering plugins"""
324
325    def __init__(self, core):
326        self.core = core
327        self.filenames = os.environ.get('GPODDER_EXTENSIONS', '').split()
328        self.containers = []
329
330        core.config.add_observer(self._config_value_changed)
331        enabled_extensions = core.config.extensions.enabled
332
333        if os.environ.get('GPODDER_DISABLE_EXTENSIONS', '') != '':
334            logger.info('Disabling all extensions (from environment)')
335            return
336
337        for name, filename in self._find_extensions():
338            logger.debug('Found extension "%s" in %s', name, filename)
339            config = getattr(core.config.extensions, name)
340            container = ExtensionContainer(self, name, config, filename)
341            if (name in enabled_extensions or
342                    container.metadata.mandatory_in_current_ui):
343                container.set_enabled(True)
344            if (name in enabled_extensions and
345                    container.metadata.disable_in_current_ui):
346                container.set_enabled(False)
347            self.containers.append(container)
348
349    def shutdown(self):
350        for container in self.containers:
351            container.set_enabled(False)
352
353    def _config_value_changed(self, name, old_value, new_value):
354        if name != 'extensions.enabled':
355            return
356
357        for container in self.containers:
358            new_enabled = (container.name in new_value)
359            if new_enabled == container.enabled:
360                continue
361            if not new_enabled and container.metadata.mandatory_in_current_ui:
362                # forced extensions are never listed in extensions.enabled
363                continue
364
365            logger.info('Extension "%s" is now %s', container.name,
366                    'enabled' if new_enabled else 'disabled')
367            container.set_enabled(new_enabled)
368            if new_enabled and not container.enabled:
369                logger.warn('Could not enable extension: %s',
370                        container.error)
371                self.core.config.extensions.enabled = [x
372                        for x in self.core.config.extensions.enabled
373                        if x != container.name]
374
375    def _find_extensions(self):
376        extensions = {}
377
378        if not self.filenames:
379            builtins = os.path.join(gpodder.prefix, 'share', 'gpodder',
380                'extensions', '*.py')
381            user_extensions = os.path.join(gpodder.home, 'Extensions', '*.py')
382            self.filenames = glob.glob(builtins) + glob.glob(user_extensions)
383
384        # Let user extensions override built-in extensions of the same name
385        for filename in self.filenames:
386            if not filename or not os.path.exists(filename):
387                logger.info('Skipping non-existing file: %s', filename)
388                continue
389
390            name, _ = os.path.splitext(os.path.basename(filename))
391            extensions[name] = filename
392
393        return sorted(extensions.items())
394
395    def get_extensions(self):
396        """Get a list of all loaded extensions and their enabled flag"""
397        return [c for c in self.containers
398            if c.metadata.available_for_current_ui and
399            not c.metadata.mandatory_in_current_ui and
400            not c.metadata.disable_in_current_ui]
401
402    # Define all known handler functions here, decorate them with the
403    # "call_extension" decorator to forward all calls to extension scripts that have
404    # the same function defined in them. If the handler functions here contain
405    # any code, it will be called after all the extensions have been called.
406
407    @call_extensions
408    def on_ui_initialized(self, model, update_podcast_callback,
409            download_episode_callback):
410        """Called when the user interface is initialized.
411
412        @param model: A gpodder.model.Model instance
413        @param update_podcast_callback: Function to update a podcast feed
414        @param download_episode_callback: Function to download an episode
415        """
416        pass
417
418    @call_extensions
419    def on_podcast_subscribe(self, podcast):
420        """Called when the user subscribes to a new podcast feed.
421
422        @param podcast: A gpodder.model.PodcastChannel instance
423        """
424        pass
425
426    @call_extensions
427    def on_podcast_updated(self, podcast):
428        """Called when a podcast feed was updated
429
430        This extension will be called even if there were no new episodes.
431
432        @param podcast: A gpodder.model.PodcastChannel instance
433        """
434        pass
435
436    @call_extensions
437    def on_podcast_update_failed(self, podcast, exception):
438        """Called when a podcast update failed.
439
440        @param podcast: A gpodder.model.PodcastChannel instance
441
442        @param exception: The reason.
443        """
444        pass
445
446    @call_extensions
447    def on_podcast_save(self, podcast):
448        """Called when a podcast is saved to the database
449
450        This extensions will be called when the user edits the metadata of
451        the podcast or when the feed was updated.
452
453        @param podcast: A gpodder.model.PodcastChannel instance
454        """
455        pass
456
457    @call_extensions
458    def on_podcast_delete(self, podcast):
459        """Called when a podcast is deleted from the database
460
461        @param podcast: A gpodder.model.PodcastChannel instance
462        """
463        pass
464
465    @call_extensions
466    def on_episode_playback(self, episode):
467        """Called when an episode is played back
468
469        This function will be called when the user clicks on "Play" or
470        "Open" in the GUI to open an episode with the media player.
471
472        @param episode: A gpodder.model.PodcastEpisode instance
473        """
474        pass
475
476    @call_extensions
477    def on_episode_save(self, episode):
478        """Called when an episode is saved to the database
479
480        This extension will be called when a new episode is added to the
481        database or when the state of an existing episode is changed.
482
483        @param episode: A gpodder.model.PodcastEpisode instance
484        """
485        pass
486
487    @call_extensions
488    def on_episode_downloaded(self, episode):
489        """Called when an episode has been downloaded
490
491        You can retrieve the filename via episode.local_filename(False)
492
493        @param episode: A gpodder.model.PodcastEpisode instance
494        """
495        pass
496
497    @call_extensions
498    def on_all_episodes_downloaded(self):
499        """Called when all episodes has been downloaded
500        """
501        pass
502
503    @call_extensions
504    def on_episode_synced(self, device, episode):
505        """Called when an episode has been synced to device
506
507        You can retrieve the filename via episode.local_filename(False)
508        For MP3PlayerDevice:
509            You can retrieve the filename on device via
510                device.get_episode_file_on_device(episode)
511            You can retrieve the folder name on device via
512                device.get_episode_folder_on_device(episode)
513
514        @param device: A gpodder.sync.Device instance
515        @param episode: A gpodder.model.PodcastEpisode instance
516        """
517        pass
518
519    @call_extensions
520    def on_create_menu(self):
521        """Called when the Extras menu is created
522
523        You can add additional Extras menu entries here. You have to return a
524        list of tuples, where the first item is a label and the second item is a
525        callable that will get no parameter.
526
527        Example return value:
528
529        [('Sync to Smartphone', lambda : ...)]
530        """
531        pass
532
533    @call_extensions
534    def on_episodes_context_menu(self, episodes):
535        """Called when the episode list context menu is opened
536
537        You can add additional context menu entries here. You have to
538        return a list of tuples, where the first item is a label and
539        the second item is a callable that will get the episode as its
540        first and only parameter.
541
542        Example return value:
543
544        [('Mark as new', lambda episodes: ...)]
545
546        @param episodes: A list of gpodder.model.PodcastEpisode instances
547        """
548        pass
549
550    @call_extensions
551    def on_channel_context_menu(self, channel):
552        """Called when the channel list context menu is opened
553
554        You can add additional context menu entries here. You have to return a
555        list of tuples, where the first item is a label and the second item is a
556        callable that will get the channel as its first and only parameter.
557
558        Example return value:
559
560        [('Update channel', lambda channel: ...)]
561        @param channel: A gpodder.model.PodcastChannel instance
562        """
563        pass
564
565    @call_extensions
566    def on_episode_delete(self, episode, filename):
567        """Called just before the episode's disk file is about to be
568        deleted."""
569        pass
570
571    @call_extensions
572    def on_episode_removed_from_podcast(self, episode):
573        """Called just before the episode is about to be removed from
574        the podcast channel, e.g., when the episode has not been
575        downloaded and it disappears from the feed.
576
577        @param podcast: A gpodder.model.PodcastChannel instance
578        """
579        pass
580
581    @call_extensions
582    def on_notification_show(self, title, message):
583        """Called when a notification should be shown
584
585        @param title: title of the notification
586        @param message: message of the notification
587        """
588        pass
589
590    @call_extensions
591    def on_download_progress(self, progress):
592        """Called when the overall download progress changes
593
594        @param progress: The current progress value (0..1)
595        """
596        pass
597
598    @call_extensions
599    def on_ui_object_available(self, name, ui_object):
600        """Called when an UI-specific object becomes available
601
602        XXX: Experimental. This hook might go away without notice (and be
603        replaced with something better). Only use for in-tree extensions.
604
605        @param name: The name/ID of the object
606        @param ui_object: The object itself
607        """
608        pass
609
610    @call_extensions
611    def on_application_started(self):
612        """Called when the application started.
613
614        This is for extensions doing stuff at startup that they don't
615        want to do if they have just been enabled.
616        e.g. minimize at startup should not minimize the application when
617        enabled but only on following startups.
618
619        It is called after on_ui_object_available and on_ui_initialized.
620        """
621        pass
622
623    @call_extensions
624    def on_find_partial_downloads_done(self):
625        """Called when the application started and the lookout for resume is done
626
627        This is mainly for extensions scheduling refresh or downloads at startup,
628        to prevent race conditions with the find_partial_downloads method.
629
630        It is called after on_application_started.
631        """
632        pass
633
634    @call_extensions
635    def on_preferences(self):
636        """Called when the preferences dialog is opened
637
638        You can add additional tabs to the preferences dialog here. You have to
639        return a list of tuples, where the first item is a label and the second
640        item is a callable with no parameters and returns a Gtk widget.
641
642        Example return value:
643
644        [('Tab name', lambda: ...)]
645        """
646        pass
647
648    @call_extensions
649    def on_channel_settings(self, channel):
650        """Called when a channel settings dialog is opened
651
652        You can add additional tabs to the channel settings dialog here. You
653        have to return a list of tuples, where the first item is a label and the
654        second item is a callable that will get the channel as its first and
655        only parameter and returns a Gtk widget.
656
657        Example return value:
658
659        [('Tab name', lambda channel: ...)]
660
661        @param channel: A gpodder.model.PodcastChannel instance
662        """
663        pass
664