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