diff options
Diffstat (limited to 'plugin.video.catchuptvandmore/resources/lib/simpleplugin.py')
-rw-r--r--[-rwxr-xr-x] | plugin.video.catchuptvandmore/resources/lib/simpleplugin.py | 403 |
1 files changed, 350 insertions, 53 deletions
diff --git a/plugin.video.catchuptvandmore/resources/lib/simpleplugin.py b/plugin.video.catchuptvandmore/resources/lib/simpleplugin.py index f3b6f9d..6b1e90d 100755..100644 --- a/plugin.video.catchuptvandmore/resources/lib/simpleplugin.py +++ b/plugin.video.catchuptvandmore/resources/lib/simpleplugin.py @@ -11,6 +11,7 @@ SimplePlugin micro-framework for Kodi content plugins import os import sys import re +import inspect from datetime import datetime, timedelta import cPickle as pickle from urlparse import parse_qs @@ -21,15 +22,20 @@ from copy import deepcopy from types import GeneratorType from hashlib import md5 from shutil import move +from contextlib import contextmanager +from pprint import pformat import xbmcaddon import xbmc import xbmcplugin import xbmcgui -__all__ = ['SimplePluginError', 'Storage', 'Addon', 'Plugin', 'Params'] +__all__ = ['SimplePluginError', 'Storage', 'MemStorage', + 'Addon', 'Plugin', 'Params', 'debug_exception'] -ListContext = namedtuple('ListContext', ['listing', 'succeeded', 'update_listing', 'cache_to_disk', - 'sort_methods', 'view_mode', 'content']) +ListContext = namedtuple('ListContext', ['listing', 'succeeded', + 'update_listing', 'cache_to_disk', + 'sort_methods', 'view_mode', + 'content', 'category']) PlayContext = namedtuple('PlayContext', ['path', 'play_item', 'succeeded']) @@ -38,6 +44,71 @@ class SimplePluginError(Exception): pass +def _format_vars(variables): + """ + Format variables dictionary + + :param variables: variables dict + :type variables: dict + :return: formatted string with sorted ``var = val`` pairs + :rtype: str + """ + var_list = [(var, val) for var, val in variables.iteritems()] + lines = [] + for var, val in sorted(var_list, key=lambda i: i[0]): + if not (var.startswith('__') or var.endswith('__')): + lines.append('{0} = {1}'.format(var, pformat(val))) + return '\n'.join(lines) + + +@contextmanager +def debug_exception(logger=None): + """ + Diagnostic helper context manager + + It controls execution within its context and writes extended + diagnostic info to the Kodi log if an unhandled exception + happens within the context. The info includes the following items: + + - Module path. + - Code fragment where the exception has happened. + - Global variables. + - Local variables. + + After logging the diagnostic info the exception is re-raised. + + Example:: + + with debug_exception(): + # Some risky code + raise RuntimeError('Fatal error!') + + :param logger: logger function which must accept a single argument + which is a log message. By default it is :func:`xbmc.log` + with ``ERROR`` level. + """ + try: + yield + except: + if logger is None: + logger = lambda msg: xbmc.log(msg, xbmc.LOGERROR) + logger('Unhandled exception detected!') + logger('*** Start diagnostic info ***') + frame_info = inspect.trace(5)[-1] + logger('File: {0}'.format(frame_info[1])) + context = '' + for i, line in enumerate(frame_info[4], frame_info[2] - frame_info[5]): + if i == frame_info[2]: + context += '{0}:>{1}'.format(str(i).rjust(5), line) + else: + context += '{0}: {1}'.format(str(i).rjust(5), line) + logger('Code context:\n' + context) + logger('Global variables:\n' + _format_vars(frame_info[0].f_globals)) + logger('Local variables:\n' + _format_vars(frame_info[0].f_locals)) + logger('**** End diagnostic info ****') + raise + + class Params(dict): """ Params(**kwargs) @@ -47,6 +118,8 @@ class Params(dict): Parameters can be accessed both through :class:`dict` keys and instance properties. + .. note:: For a missing parameter an instance property returns ``None``. + Example: .. code-block:: python @@ -56,10 +129,8 @@ class Params(dict): foo = params['foo'] # Access by key bar = params.bar # Access through property. Both variants are equal """ - def __getattr__(self, item): - if item not in self: - raise AttributeError('Invalid parameter: "{0}"!'.format(item)) - return self[item] + def __getattr__(self, key): + return self.get(key) def __str__(self): return '<Params {0}>'.format(super(Params, self).__repr__()) @@ -70,6 +141,8 @@ class Params(dict): class Storage(MutableMapping): """ + Storage(storage_dir, filename='storage.pcl') + Persistent storage for arbitrary data with a dictionary-like interface It is designed as a context manager and better be used @@ -107,7 +180,7 @@ class Storage(MutableMapping): def __enter__(self): return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, t, v, tb): self.flush() def __getitem__(self, key): @@ -120,7 +193,7 @@ class Storage(MutableMapping): del self._storage[key] def __iter__(self): - return self._storage.__iter__() + return iter(self._storage) def __len__(self): return len(self._storage) @@ -163,6 +236,106 @@ class Storage(MutableMapping): return deepcopy(self._storage) +class MemStorage(MutableMapping): + """ + MemStorage(storage_id) + + In-memory storage with dict-like interface + + The data is stored in the Kodi core so contents of a MemStorage instance + with the same ID can be shared between different Python processes. + + .. note:: Keys are case-insensitive + + .. warning:: :class:`MemStorage` does not allow to modify mutable objects + in place! You need to assign them to variables first, modify and + store them back to a MemStorage instance. + + Example: + + .. code-block:: python + + storage = MemStorage('foo') + some_list = storage['bar'] + some_list.append('spam') + storage['bar'] = some_list + + :param storage_id: ID of this storage instance + :type storage_id: str + :param window_id: the ID of a Kodi Window object where storage contents + will be stored. + :type window_id: int + """ + def __init__(self, storage_id, window_id=10000): + self._id = storage_id + self._window = xbmcgui.Window(window_id) + try: + self['__keys__'] + except KeyError: + self['__keys__'] = [] + + def _check_key(self, key): + if not isinstance(key, str): + raise TypeError('Storage key must be of str type!') + + def _format_contents(self): + lines = [] + for key, val in self.iteritems(): + lines.append('{0}: {1}'.format(repr(key), repr(val))) + return ', '.join(lines) + + def __str__(self): + return '<MemStorage {{{0}}}>'.format(self._format_contents()) + + def __repr__(self): + return '<simpleplugin.MemStorage object {{{0}}}'.format(self._format_contents()) + + def __getitem__(self, key): + self._check_key(key) + full_key = '{0}__{1}'.format(self._id, key) + raw_item = self._window.getProperty(full_key) + if raw_item: + return pickle.loads(raw_item) + else: + raise KeyError(key) + + def __setitem__(self, key, value): + self._check_key(key) + full_key = '{0}__{1}'.format(self._id, key) + self._window.setProperty(full_key, pickle.dumps(value)) + if key != '__keys__': + keys = self['__keys__'] + keys.append(key) + self['__keys__'] = keys + + def __delitem__(self, key): + self._check_key(key) + full_key = '{0}__{1}'.format(self._id, key) + item = self._window.getProperty(full_key) + if item: + self._window.clearProperty(full_key) + if key != '__keys__': + keys = self['__keys__'] + keys.remove(key) + self['__keys__'] = keys + else: + raise KeyError(key) + + def __contains__(self, key): + self._check_key(key) + full_key = '{0}__{1}'.format(self._id, key) + item = self._window.getProperty(full_key) + if item: + return True + return False + + def __iter__(self): + return iter(self['__keys__']) + + def __len__(self): + return len(self['__keys__']) + + class Addon(object): """ Base addon class @@ -358,7 +531,7 @@ class Addon(object): :param message: message to write to the Kodi log :type message: str """ - self.log(message, xbmc.LOGINFO) + self.log(message, xbmc.LOGNOTICE) def log_warning(self, message): """ @@ -408,6 +581,62 @@ class Addon(object): """ return Storage(self.config_dir, filename) + def get_mem_storage(self, storage_id='', window_id=10000): + """ + Creates an in-memory storage for this addon with :class:`dict`-like + interface + + The storage can store picklable Python objects as long as + Kodi is running and storage contents can be shared between + Python processes. Different addons have separate storages, + so storages with the same names created with this method + do not conflict. + + Example:: + + addon = Addon() + storage = addon.get_mem_storage() + foo = storage['foo'] + storage['bar'] = bar + + :param storage_id: optional storage ID (case-insensitive). + :type storage_id: str + :param window_id: the ID of a Kodi Window object where storage contents + will be stored. + :type window_id: int + :return: in-memory storage for this addon + :rtype: MemStorage + """ + if storage_id: + storage_id = '{0}_{1}'.format(self.id, storage_id) + return MemStorage(storage_id, window_id) + + def _get_cached_data(self, cache, func, duration, *args, **kwargs): + """ + Get data from a cache object + + :param cache: cache object + :param func: function to cache + :param duration: cache duration + :param args: function args + :param kwargs: function kwargs + :return: function return data + """ + if duration <= 0: + raise ValueError('Caching duration cannot be zero or negative!') + current_time = datetime.now() + key = func.__name__ + str(args) + str(kwargs) + try: + data, timestamp = cache[key] + if current_time - timestamp > timedelta(minutes=duration): + raise KeyError + self.log_debug('Cache hit: {0}'.format(key)) + except KeyError: + self.log_debug('Cache miss: {0}'.format(key)) + data = func(*args, **kwargs) + cache[key] = (data, current_time) + return data + def cached(self, duration=10): """ Cached decorator @@ -429,20 +658,30 @@ class Addon(object): @wraps(func) def inner_wrapper(*args, **kwargs): with self.get_storage('__cache__.pcl') as cache: - current_time = datetime.now() - key = func.__name__ + str(args) + str(kwargs) - try: - data, timestamp = cache[key] - if duration > 0 and current_time - timestamp > timedelta(minutes=duration): - raise KeyError - elif duration <= 0: - raise ValueError('Caching duration cannot be zero or negative!') - self.log_debug('Cache hit: {0}'.format(key)) - except KeyError: - self.log_debug('Cache miss: {0}'.format(key)) - data = func(*args, **kwargs) - cache[key] = (data, current_time) - return data + return self._get_cached_data(cache, func, duration, *args, **kwargs) + return inner_wrapper + return outer_wrapper + + def mem_cached(self, duration=10): + """ + In-memory cache decorator + + Usage:: + + @plugin.mem_cached(30) + def my_func(*args, **kwargs): + # Do some stuff + return value + + :param duration: caching duration in min (positive values only) + :type duration: int + :raises ValueError: if duration is zero or negative + """ + def outer_wrapper(func): + @wraps(func) + def inner_wrapper(*args, **kwargs): + cache = self.get_mem_storage('***cache***') + return self._get_cached_data(cache, func, duration, *args, **kwargs) return inner_wrapper return outer_wrapper @@ -462,7 +701,7 @@ class Addon(object): :type ui_string: str :return: a UI string from translated :file:`strings.po`. :rtype: unicode - :raises simpleplugin.SimplePluginError: if :meth:`Addon.initialize_gettext` wasn't called first + :raises SimplePluginError: if :meth:`Addon.initialize_gettext` wasn't called first or if a string is not found in English :file:`strings.po`. """ if self._ui_strings_map is not None: @@ -502,9 +741,11 @@ class Addon(object): with localized versions if these strings are translated. :return: :meth:`Addon.gettext` method object - :raises simpleplugin.SimplePluginError: if the addon's English :file:`strings.po` file is missing + :raises SimplePluginError: if the addon's English :file:`strings.po` file is missing """ strings_po = os.path.join(self.path, 'resources', 'language', 'resource.language.en_gb', 'strings.po') + if not os.path.exists(strings_po): + strings_po = os.path.join(self.path, 'resources', 'language', 'English', 'strings.po') if os.path.exists(strings_po): with open(strings_po, 'rb') as fo: raw_strings = fo.read() @@ -563,20 +804,20 @@ class Plugin(Addon): plugin = Plugin() @plugin.action() - def root(params): # Mandatory item! + def root(): # Mandatory item! return [{'label': 'Foo', - 'url': plugin.get_url(action='some_action', param='Foo')}, + 'url': plugin.get_url(action='some_action', label='Foo')}, {'label': 'Bar', - 'url': plugin.get_url(action='some_action', param='Bar')}] + 'url': plugin.get_url(action='some_action', label='Bar')}] @plugin.action() def some_action(params): - return [{'label': params['param']}] + return [{'label': params.label]}] plugin.run() - An action callable receives 1 parameter -- params. - params is a dict-like object containing plugin call parameters (including action string) + An action callable may receive 1 optional parameter which is + a dict-like object containing plugin call parameters (including action string) The action callable can return either a list/generator of dictionaries representing Kodi virtual directory items or a resolved playable path (:class:`str` or :obj:`unicode`) for Kodi to play. @@ -626,6 +867,21 @@ class Plugin(Addon): except for ``'url'`` and ``'is_folder'``, are ignored. - properties -- a dictionary of list item properties (see :meth:`xbmcgui.ListItem.setProperty`) -- optional. + - cast -- a list of cast info (actors, roles, thumbnails) for the list item + (see :meth:`xbmcgui.ListItem.setCast`) -- optional. + - offscreen -- if ``True`` do not lock GUI (used for Python scrapers and subtitle plugins) -- + optional. + - content_lookup -- if ``False``, do not HEAD requests to get mime type. Optional. + - online_db_ids -- a :class:`dict` of ``{'label': 'value'}`` pairs representing + the item's IDs in popular online databases. Possible labels: 'imdb', 'tvdb', + 'tmdb', 'anidb', see :meth:`xbmcgui.ListItem.setUniqueIDs`. Optional. + - ratings -- a :class:`list` of :class:`dict`s with the following keys: + 'type' (:class:`str`), 'rating' (:class:`float`), + 'votes' (:class:`int`, optional), 'defaultt' (:class:`bool`, optional). + This list sets item's ratings in popular online databases. + Possible types: 'imdb', 'tvdb', tmdb', 'anidb'. + See :meth:`xbmcgui.ListItem.setRating`. Optional. + Example 3:: @@ -743,7 +999,7 @@ class Plugin(Addon): :param name: action's name (optional). :type name: str - :raises simpleplugin.SimplePluginError: if the action with such name is already defined. + :raises SimplePluginError: if the action with such name is already defined. """ def wrap(func, name=name): if name is None: @@ -754,49 +1010,55 @@ class Plugin(Addon): return func return wrap - def run(self, category=''): + def run(self, category=None): """ Run plugin - :param category: str - plugin sub-category, e.g. 'Comedy'. - See :func:`xbmcplugin.setPluginCategory` for more info. - :type category: str - :raises simpleplugin.SimplePluginError: if unknown action string is provided. + :raises SimplePluginError: if unknown action string is provided. """ - self._handle = int(sys.argv[1]) if category: - xbmcplugin.setPluginCategory(self._handle, category) + self.log_warning( + 'Deprecation warning: Plugin category is no longer set via Plugin.run(). ' + 'Use "category" parameter of Plugin.create_listing() instead.' + ) + self._handle = int(sys.argv[1]) params = self.get_params(sys.argv[2][1:]) action = params.get('action', 'root') self.log_debug(str(self)) self.log_debug('Actions: {0}'.format(str(self.actions.keys()))) - self.log_debug('Called action "{0}" with params "{1}"'.format(action, str(params))) + self.log_debug('Called action "{0}" with params "{1}"'.format( + action, str(params)) + ) try: action_callable = self.actions[action] except KeyError: raise SimplePluginError('Invalid action: "{0}"!'.format(action)) else: - result = action_callable(params) + # inspect.isfunction is needed for tests + if inspect.isfunction(action_callable) and not inspect.getargspec(action_callable).args: + result = action_callable() + else: + result = action_callable(params) self.log_debug('Action return value: {0}'.format(str(result))) if isinstance(result, (list, GeneratorType)): self._add_directory_items(self.create_listing(result)) elif isinstance(result, basestring): self._set_resolved_url(self.resolve_url(result)) - elif isinstance(result, tuple) and hasattr(result, 'listing'): + elif isinstance(result, ListContext): self._add_directory_items(result) - elif isinstance(result, tuple) and hasattr(result, 'path'): + elif isinstance(result, PlayContext): self._set_resolved_url(result) else: self.log_debug('The action "{0}" has not returned any valid data to process.'.format(action)) @staticmethod def create_listing(listing, succeeded=True, update_listing=False, cache_to_disk=False, sort_methods=None, - view_mode=None, content=None): + view_mode=None, content=None, category=None): """ Create and return a context dict for a virtual folder listing :param listing: the list of the plugin virtual folder items - :type listing: :class:`list` or :class:`types.GeneratorType` + :type listing: list or types.GeneratorType :param succeeded: if ``False`` Kodi won't open a new listing and stays on the current level. :type succeeded: bool :param update_listing: if ``True``, Kodi won't open a sub-listing but refresh the current one. @@ -811,11 +1073,15 @@ class Plugin(Addon): :param content: string - current plugin content, e.g. 'movies' or 'episodes'. See :func:`xbmcplugin.setContent` for more info. :type content: str + :param category: str - plugin sub-category, e.g. 'Comedy'. + See :func:`xbmcplugin.setPluginCategory` for more info. + :type category: str :return: context object containing necessary parameters to create virtual folder listing in Kodi UI. :rtype: ListContext """ - return ListContext(listing, succeeded, update_listing, cache_to_disk, sort_methods, view_mode, content) + return ListContext(listing, succeeded, update_listing, cache_to_disk, + sort_methods, view_mode, content, category) @staticmethod def resolve_url(path='', play_item=None, succeeded=True): @@ -847,15 +1113,25 @@ class Plugin(Addon): :return: ListItem instance :rtype: xbmcgui.ListItem """ - list_item = xbmcgui.ListItem(label=item.get('label', ''), - label2=item.get('label2', ''), - path=item.get('path', '')) - if int(xbmc.getInfoLabel('System.BuildVersion')[:2]) >= 16: + major_version = xbmc.getInfoLabel('System.BuildVersion')[:2] + if major_version >= '18': + list_item = xbmcgui.ListItem(label=item.get('label', ''), + label2=item.get('label2', ''), + path=item.get('path', ''), + offscreen=item.get('offscreen', False)) + else: + list_item = xbmcgui.ListItem(label=item.get('label', ''), + label2=item.get('label2', ''), + path=item.get('path', '')) + if major_version >= '16': art = item.get('art', {}) art['thumb'] = item.get('thumb', '') art['icon'] = item.get('icon', '') art['fanart'] = item.get('fanart', '') item['art'] = art + cont_look = item.get('content_lookup') + if cont_look is not None: + list_item.setContentLookup(cont_look) else: list_item.setThumbnailImage(item.get('thumb', '')) list_item.setIconImage(item.get('icon', '')) @@ -877,6 +1153,17 @@ class Plugin(Addon): if item.get('properties'): for key, value in item['properties'].iteritems(): list_item.setProperty(key, value) + if major_version >= '17': + cast = item.get('cast') + if cast is not None: + list_item.setCast(cast) + db_ids = item.get('online_db_ids') + if db_ids is not None: + list_item.setUniqueIDs(db_ids) + ratings = item.get('ratings') + if ratings is not None: + for rating in ratings: + list_item.setRating(**rating) return list_item def _add_directory_items(self, context): @@ -885,8 +1172,11 @@ class Plugin(Addon): :param context: context object :type context: ListContext + :raises SimplePluginError: if sort_methods parameter is not int, tuple or list """ self.log_debug('Creating listing from {0}'.format(str(context))) + if context.category is not None: + xbmcplugin.setPluginCategory(self._handle, context.category) if context.content is not None: xbmcplugin.setContent(self._handle, context.content) # This must be at the beginning for item in context.listing: @@ -900,7 +1190,14 @@ class Plugin(Addon): is_folder = False xbmcplugin.addDirectoryItem(self._handle, item['url'], list_item, is_folder) if context.sort_methods is not None: - [xbmcplugin.addSortMethod(self._handle, method) for method in context.sort_methods] + if isinstance(context.sort_methods, int): + xbmcplugin.addSortMethod(self._handle, context.sort_methods) + elif isinstance(context.sort_methods, (tuple, list)): + for method in context.sort_methods: + xbmcplugin.addSortMethod(self._handle, method) + else: + raise TypeError( + 'sort_methods parameter must be of int, tuple or list type!') xbmcplugin.endOfDirectory(self._handle, context.succeeded, context.update_listing, @@ -920,4 +1217,4 @@ class Plugin(Addon): list_item = xbmcgui.ListItem(path=context.path) else: list_item = self.create_list_item(context.play_item) - xbmcplugin.setResolvedUrl(self._handle, context.succeeded, list_item)
\ No newline at end of file + xbmcplugin.setResolvedUrl(self._handle, context.succeeded, list_item) |