From b984a7c29852b113dc2e982eda68ba6f2f263aae Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Mon, 1 Mar 2021 21:51:11 +0700 Subject: [PATCH 01/23] add modified kodi_mock and cli --- plugin.program.autowidget/cli.py | 97 ++ .../mock_kodi/__init__.py | 1179 +++++++++++++++++ requirements.txt | 24 + 3 files changed, 1300 insertions(+) create mode 100644 plugin.program.autowidget/cli.py create mode 100644 plugin.program.autowidget/mock_kodi/__init__.py create mode 100644 requirements.txt diff --git a/plugin.program.autowidget/cli.py b/plugin.program.autowidget/cli.py new file mode 100644 index 00000000..49a9d8ca --- /dev/null +++ b/plugin.program.autowidget/cli.py @@ -0,0 +1,97 @@ +from mock_kodi import MOCK +import os +import threading +import xbmcgui +import xbmcplugin +import xbmcaddon +import xbmc +from mock_kodi import makedirs +import runpy +from urllib.parse import urlparse +import sys + + +def execute_callback(): + runpy.run_module('foobar', run_name='__main__') + +def start_service(): + from resources.lib import refresh + _monitor = refresh.RefreshService() + _monitor.waitForAbort() + +def setup(): + #item.setInfo('video', def_info) + #item.setMimeType(def_info.get('mimetype', '')) + #item.setArt(def_art) + #item.addContextMenuItems(def_cm) + _addon = xbmcaddon.Addon() + # create dirs + _addon_id = _addon.getAddonInfo('id') + _addon_path = xbmc.translatePath(_addon.getAddonInfo('profile')) + _addon_root = xbmc.translatePath(_addon.getAddonInfo('path')) + makedirs(_addon_path, exist_ok=True) + + + + # load the context menus + #_addon._config + + # + # + # String.IsEqual(Window(10000).Property(context.autowidget),true) + # + MOCK.DIRECTORY.register_contextmenu( + _addon.getLocalizedString(32003), + "plugin.program.autowidget", + "context_add", + lambda : True + ) + + # + # + # String.Contains(ListItem.FolderPath, plugin://plugin.program.autowidget) + # + MOCK.DIRECTORY.register_contextmenu( + _addon.getLocalizedString(32006), + "plugin.program.autowidget", + "context_refresh", + lambda : True + ) + + MOCK.DIRECTORY.register_action("plugin://plugin.program.autowidget", "main") + + def home(path): + xbmcplugin.addDirectoryItem( + handle=1, + url="plugin://plugin.program.autowidget/", + listitem=xbmcgui.ListItem("AutoWidget"), + isFolder=True + ) + # add our fake plugin + xbmcplugin.addDirectoryItem( + handle=1, + url="plugin://dummy/", + listitem=xbmcgui.ListItem("Dummy"), + isFolder=True + ) + xbmcplugin.endOfDirectory(handle=1) + MOCK.DIRECTORY.register_action("", home) + + def dummy_folder(path): + for i in range(1,20): + p = "plugin://dummy/?item={}".format(i) + xbmcplugin.addDirectoryItem( + handle=1, + url=p, + listitem=xbmcgui.ListItem("Dummy Item {}".format(i), path=p), + isFolder=False + ) + xbmcplugin.endOfDirectory(handle=1) + MOCK.DIRECTORY.register_action("plugin://dummy", dummy_folder) + + +if __name__ == '__main__': + os.environ['SEREN_INTERACTIVE_MODE'] = 'True' + #t = threading.Thread(target=start_service).start() + setup() + MOCK.DIRECTORY.handle_directory() diff --git a/plugin.program.autowidget/mock_kodi/__init__.py b/plugin.program.autowidget/mock_kodi/__init__.py new file mode 100644 index 00000000..44f2c0e1 --- /dev/null +++ b/plugin.program.autowidget/mock_kodi/__init__.py @@ -0,0 +1,1179 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals, print_function + +import functools +import json +import os +import re +import shutil +import sys +import time +import types +import runpy +from urllib.parse import urlparse + +import polib + +try: + WindowsError = WindowsError +except NameError: + WindowsError = Exception + + +try: + import xml.etree.cElementTree as ElementTree +except ImportError: + import xml.etree.ElementTree as ElementTree + +import xbmc +import xbmcaddon +import xbmcdrm +import xbmcgui +import xbmcplugin +import xbmcvfs + +PYTHON3 = True if sys.version_info.major == 3 else False +PYTHON2 = not PYTHON3 + +if PYTHON2: + get_input = raw_input # noqa +else: + get_input = input + +SUPPORTED_LANGUAGES = { + "en-de": ("en-de", "eng-deu", "English-Central Europe"), + "en-aus": ("en-aus", "eng-aus", "English-Australia (12h)"), + "en-gb": ("en-gb", "eng-gbr", "English-UK (12h)"), + "en-us": ("en-us", "eng-usa", "English-USA (12h)"), + "de-de": ("de-de", "ger-deu", "German-Deutschland"), + "nl-nl": ("nl-nl", "dut-nld", "Dutch-NL"), +} + +PLUGIN_NAME = "plugin.program.autowidget" + +def makedirs(name, mode=0o777, exist_ok=False): + """makedirs(name [, mode=0o777][, exist_ok=False]) + Super-mkdir; create a leaf directory and all intermediate ones. Works like + mkdir, except that any intermediate path segment (not just the rightmost) + will be created if it does not exist. If the target directory already + exists, raise an OSError if exist_ok is False. Otherwise no exception is + raised. This is recursive. + :param name:Name of the directory to be created + :type name:str|unicode + :param mode:Unix file mode for created directories + :type mode:int + :param exist_ok:Boolean to indicate whether is should raise on an exception + :type exist_ok:bool + """ + try: + os.makedirs(name, mode, exist_ok) + except (OSError, FileExistsError, WindowsError): + if not exist_ok: + raise + +class Directory: + """Directory class to keep track of items added to the virtual directory of the mock""" + + def __init__(self): + pass + + history = [] + items = [] + last_action = "" + next_action = "" + current_list_item = None + current_container_item = None + content = {} + sort_method = {} + action_callbacks = {} + context_callbacks = [] + + def handle_directory(self): + """ + :return: + :rtype: + """ + if not MOCK.INTERACTIVE_MODE: + return + + while True: + self.items = [] + self._execute_action() + self.current_container_item = self.current_list_item + self.current_list_item = None + + while True: + + if self.next_action != self.last_action: + self.history.append(self.last_action) + self.last_action = self.next_action + + print("-------------------------------") + print("-1) Back") + print(" 0) Home") + print("-------------------------------") + for idx, item in enumerate(self.items): + print(" {}) {}".format(idx + 1, item[1])) + + print("") + print("Enter Action Number") + action = get_input() + if self._try_handle_menu_action(action): + break + elif self._try_handle_context_menu_action(action): + break + elif self._try_handle_action(action): + break + else: + print("Please enter a valid entry") + time.sleep(1) + + # old_items = self.items + # self.items = [] + # self._execute_action() + # if not self.items: + # self.items = old_items + # self._try_handle_menu_action(-1) + # TODO: action that doesn't make a menu + + def _try_handle_menu_action(self, action): + try: + action = int(action) - 1 + except: + return False + if action == -2: + if self.history: + self.next_action = self.history.pop(-1) + self.last_action = "" + self.current_list_item = None + return True + if action == -1: + self.next_action = "" + self.current_list_item = None + return True + elif -1 < action < len(self.items): + self.next_action = self.items[action][0] + self.current_list_item = self.items[action][1] + return True + else: + return False + + def _try_handle_context_menu_action(self, action): + get_context_check = re.findall(r"^c(\d*)", action) + if len(get_context_check) == 1: + cur_item = self.items[int(get_context_check[0]) - 1] + self.current_list_item = cur_item[1] + items = [] + for context_item in cur_item[1].cm: + items.append( + (context_item[0], re.findall(r".*?\((.*?)\)", context_item[1])[0]) + ) + # Get contenxt menus from addons + for label, path, visible in self.context_callbacks: + # TODO: handle conditions + items.append( + (path, label) + ) + # Show context menu + for idx, item in enumerate(items): + print(" {}) {}".format(idx + 1, item[1])) + + print("") + action = get_input("Enter Context Menu: ") + try: + action = int(action) - 1 + except: + return False + if -1 < action < len(items): + self.next_action = items[action][0] + return True + return False + + return True + return False + + def _try_handle_action(self, action): + if action.startswith("action"): + try: + self.next_action[2] = re.findall(r"action (.*?)$", action)[0] + return True + except: + print("Failed to parse action {}".format(action)) + return False + + def _execute_action(self): + #from resources.lib.modules.globals import g + + #g.init_globals(["", 0, self.next_action]) + for path,script in sorted(self.action_callbacks.items(), reverse=True): + if not self.next_action.startswith(path): + continue + if type(script) == type(""): + p = urlparse(self.next_action) + sys.argv = ["{}://{}".format(p.scheme, p.hostname), 1, "?"+p.query] + runpy.run_module(script, run_name='__main__',) + else: + script(self.next_action) + break + + # for plugin,label,script in self.context_callbacks: + # if not self.next_action.startswith('context://{}/{}'.format(plugin,label)): + # continue + # callback(self.next_action) + + def get_items_dictionary(self): + """ + :return: + :rtype: + """ + result = json.loads(json.dumps(self.items, cls=JsonEncoder)) + self.items = [] + return result + + def getInfoLabel(self, value): + # handle ListItem or Container infolabels + ListItem = self.current_list_item + Container = self.current_container_item + value = value.replace(".", ".get") + res = eval(value, locals()) + if callable(res): + return res() + else: + return res + + + def register_action(self, path, script): + # TODO: read config from the addon.xml for actions and context menu + self.action_callbacks[path] = script + + def register_contextmenu(self, label, plugin, module, visible=None): + # TODO: read config from the addon.xml for actions and context menu + path = "plugin://{}/{}".format(plugin, module) # HACK: there is probably an offical way to encode + self.context_callbacks.append((label, path, visible)) + self.action_callbacks[path] = module + + +class SerenStubs: + @staticmethod + def create_stubs(): + """Returns the methods used in the new kodistubs monkey patcher + :return:Dictionary with the stub mapping + :rtype:dict + """ + return { + "xbmc": { + "getInfoLabel": SerenStubs.xbmc.getInfoLabel, + "translatePath": SerenStubs.xbmc.translatePath, + "log": SerenStubs.xbmc.log, + "getSupportedMedia": SerenStubs.xbmc.getSupportedMedia, + "getLanguage": SerenStubs.xbmc.getLanguage, + "getCondVisibility": SerenStubs.xbmc.getCondVisibility, + "executebuiltin": SerenStubs.xbmc.executebuiltin, + "PlayList": SerenStubs.xbmc.PlayList, + "Monitor": SerenStubs.xbmc.Monitor, + "validatePath": lambda t: t, + "sleep": lambda t: time.sleep(t / 1000), + }, + "xbmcaddon": {"Addon": SerenStubs.xbmcaddon.Addon}, + "xbmcgui": { + "ListItem": SerenStubs.xbmcgui.ListItem, + "Window": SerenStubs.xbmcgui.Window, + "Dialog": SerenStubs.xbmcgui.Dialog, + "DialogBusy": SerenStubs.xbmcgui.DialogBusy, + "DialogProgress": SerenStubs.xbmcgui.DialogProgress, + "DialogProgressBG": SerenStubs.xbmcgui.DialogProgressBG, + }, + "xbmcplugin": { + "addDirectoryItem": SerenStubs.xbmcplugin.addDirectoryItem, + "addDirectoryItems": SerenStubs.xbmcplugin.addDirectoryItems, + "endOfDirectory": SerenStubs.xbmcplugin.endOfDirectory, + "addSortMethod": SerenStubs.xbmcplugin.addSortMethod, + "setContent": SerenStubs.xbmcplugin.setContent, + }, + "xbmcvfs": { + "File": SerenStubs.xbmcvfs.open, + "exists": os.path.exists, + "mkdir": os.mkdir, + "mkdirs": os.makedirs, + "rmdir": shutil.rmtree, + "validatePath": lambda t: t, + }, + } + + class xbmc: + """Placeholder for the xbmc stubs""" + + @staticmethod + def translatePath(path): + """Returns the translated path""" + valid_dirs = [ + "xbmc", + "home", + "temp", + "masterprofile", + "profile", + "subtitles", + "userdata", + "database", + "thumbnails", + "recordings", + "screenshots", + "musicplaylists", + "videoplaylists", + "cdrips", + "skin", + ] + + if not path.startswith("special://"): + return path + parts = path.split("/")[2:] + assert len(parts) > 1, "Need at least a single root directory" + + name = parts[0] + assert name in valid_dirs, "{} is not a valid root dir.".format(name) + + parts.pop(0) # remove name property + + dir_master = os.path.join(MOCK.PROFILE_ROOT, "userdata") + + makedirs(dir_master, exist_ok=True) + + if name == "xbmc": + return os.path.join(MOCK.XBMC_ROOT, *parts) + elif name in ("home", "logpath"): + if not MOCK.RUN_AGAINST_INSTALLATION and all( + x in parts for x in ["addons", PLUGIN_NAME] + ): + return MOCK.PROFILE_ROOT + return os.path.join(MOCK.PROFILE_ROOT, *parts) + elif name in ("masterprofile", "profile"): + return os.path.join(dir_master, *parts) + elif name == "database": + return os.path.join(dir_master, "Database", *parts) + elif name == "thumbnails": + return os.path.join(dir_master, "Thumbnails", *parts) + elif name == "musicplaylists": + return os.path.join(dir_master, "playlists", "music", *parts) + elif name == "videoplaylists": + return os.path.join(dir_master, "playlists", "video", *parts) + else: + import tempfile + + tempdir = os.path.join(tempfile.gettempdir(), "XBMC", name) + makedirs(tempdir, exist_ok=True) + return os.path.join(tempdir, *parts) + + @staticmethod + def getInfoLabel(value): + """Returns information about infolabels + :param value: + :type value: + :return: + :rtype: + """ + if value == "System.BuildVersion": + if PYTHON2: + return "18" + if PYTHON3: + return "19" + elif value.startswith("ListItem.") or value.startswith("Container."): + res = MOCK.DIRECTORY.getInfoLabel(value) + if res is not None: + return res + print("Couldn't find the infolabel") + return "" + + @staticmethod + def getSupportedMedia(media): + """Returns the supported file types for the specific media as a string""" + if media == "video": + return ( + ".m4v|.3g2|.3gp|.nsv|.tp|.ts|.ty|.strm|.pls|.rm|.rmvb|.mpd|.m3u|.m3u8|.ifo|.mov|.qt|.divx|.xvid|.bivx|.vob|.nrg|.img|.iso|.pva|.wmv" + "|.asf|.asx|.ogm|.m2v|.avi|.bin|.dat|.mpg|.mpeg|.mp4|.mkv|.mk3d|.avc|.vp3|.svq3|.nuv|.viv|.dv|.fli|.flv|.rar|.001|.wpl|.zip|.vdr|.dvr" + "-ms|.xsp|.mts|.m2t|.m2ts|.evo|.ogv|.sdp|.avs|.rec|.url|.pxml|.vc1|.h264|.rcv|.rss|.mpls|.webm|.bdmv|.wtv|.pvr|.disc " + ) + elif media == "music": + return ( + ".nsv|.m4a|.flac|.aac|.strm|.pls|.rm|.rma|.mpa|.wav|.wma|.ogg|.mp3|.mp2|.m3u|.gdm|.imf|.m15|.sfx|.uni|.ac3|.dts|.cue|.aif|.aiff|.wpl" + "|.ape|.mac|.mpc|.mp+|.mpp|.shn|.zip|.rar|.wv|.dsp|.xsp|.xwav|.waa|.wvs|.wam|.gcm|.idsp|.mpdsp|.mss|.spt|.rsd|.sap|.cmc|.cmr|.dmc|.mpt" + "|.mpd|.rmt|.tmc|.tm8|.tm2|.oga|.url|.pxml|.tta|.rss|.wtv|.mka|.tak|.opus|.dff|.dsf|.cdda " + ) + elif media == "picture": + return ".png|.jpg|.jpeg|.bmp|.gif|.ico|.tif|.tiff|.tga|.pcx|.cbz|.zip|.cbr|.rar|.rss|.webp|.jp2|.apng" + return "" + + @staticmethod + def log(msg, level=xbmc.LOGDEBUG): + """Write a string to XBMC's log file and the debug window""" + if PYTHON2: + levels = [ + "LOGDEBUG", + "LOGINFO", + "LOGNOTICE", + "LOGWARNING", + "LOGERROR", + "LOGSEVERE", + "LOGFATAL", + "LOGNONE", + ] + else: + levels = [ + "LOGDEBUG", + "LOGINFO", + "LOGWARNING", + "LOGERROR", + "LOGSEVERE", + "LOGFATAL", + "LOGNONE", + ] + value = "{} - {}".format(levels[level], msg) + print(value) + MOCK.LOG_HISTORY.append(value) + + @staticmethod + def getCondVisibility(value): + if value == "Window.IsMedia": + return 0 + + @staticmethod + def getLanguage(format=xbmc.ENGLISH_NAME, region=False): + """Returns the active language as a string.""" + result = SUPPORTED_LANGUAGES.get(MOCK.KODI_UI_LANGUAGE, ())[format] + if region: + return result + else: + return result.split("-")[0] + + @staticmethod + def executebuiltin(function, wait=False): + """Execute a built in Kodi function""" + print("EXECUTE BUILTIN: {} wait:{}".format(function, wait)) + + class PlayList(xbmc.PlayList): + def __init__(self, playList): + self.list = [] + + def add(self, url, listitem=None, index=-1): + self.list.append([url, listitem]) + + def getposition(self): + return 0 + + def clear(self): + self.list.clear() + + def size(self): + return len(self.list) + + class Monitor: + def __init__(self, *args, **kwargs): + pass + + def abortRequested(self): + return False + + def waitForAbort(self, timeout=0): + time.sleep(timeout) + return True + + def onSettingsChanged(self): + pass + + class xbmcaddon: + class Addon(xbmcaddon.Addon): + def __init__(self, addon_id=None): + self._id = addon_id + self._config = {} + self._strings = {} + self._current_user_settings = {} + + def _load_addon_config(self): + # Parse the addon config + try: + filepath = os.path.join(MOCK.SEREN_ROOT, "addon.xml") + xml = ElementTree.parse(filepath) + self._config = xml.getroot() + self._id = self.getAddonInfo("id") or self._id + except ElementTree.ParseError: + pass + except IOError: + pass + + def _load_language_string(self): + only_digits = re.compile(r"\D") + + langfile = self.get_po_location( + xbmc.getLanguage( + format=xbmc.ISO_639_1, + region=True) + ) + if os.path.exists(langfile): + po = polib.pofile(langfile) + else: + po = polib.pofile(self.get_po_location("en-gb")) + self._strings = { + int(only_digits.sub("", entry.msgctxt)): entry.msgstr + if entry.msgstr + else entry.msgid + for entry in po + } + + def get_po_location(self, language): + langfile = os.path.join( + MOCK.SEREN_ROOT, + "resources", + "language", + "resource.language.{}".format(language).replace("-", "_"), + "strings.po", + ) + return langfile + + def _load_user_settings(self): + current_settings_file = os.path.join( + os.path.join( + MOCK.PROFILE_ROOT, + "userdata", + "addon_data", + PLUGIN_NAME, + "settings.xml", + ) + ) + if not os.path.exists(current_settings_file): + self._init_user_settings() + return + xml = ElementTree.parse(current_settings_file) + settings = xml.findall("./setting") + for node in settings: + setting_id = node.get("id") + setting_value = node.text + item = {"id": setting_id} + if setting_value: + item["value"] = setting_value + self._current_user_settings.update({setting_id: item}) + + def _init_user_settings(self): + settings_def_file = os.path.join( + os.path.join( + MOCK.PROFILE_ROOT, + "resources", + "settings.xml", + ) + ) + current_settings_file = os.path.join( + os.path.join( + MOCK.PROFILE_ROOT, + "userdata", + "addon_data", + PLUGIN_NAME, + "settings.xml", + ) + ) + xml = ElementTree.parse(settings_def_file) + settings = xml.findall("./category/setting") + for node in settings: + setting_id = node.get("id") + setting_value = node.get("default") + item = {"id": setting_id} + if setting_value: + item["value"] = setting_value + self._current_user_settings.update({setting_id: item}) + # TODO: write into current_settings_file + + + def getAddonInfo(self, key): + if not self._config: + self._load_addon_config() + + properties = [ + "author", + "changelog", + "description", + "disclaimer", + "fanart", + "icon", + "id", + "name", + "path", + "profile", + "stars", + "summary", + "type", + "version", + ] + if key not in properties: + raise ValueError("{} is not a valid property.".format(key)) + if key == "profile": + return "special://profile/addon_data/{0}/".format(self._id) + if key == "path": + return "special://home/addons/{0}".format(self._id) + if self._config and key in self._config.attrib: + return self._config.attrib[key] + return None + + def getLocalizedString(self, key): + if not self._strings: + self._load_language_string() + + if key in self._strings: + return kodi_to_ansi(self._strings[key]) + print("Cannot find localized string {}".format(key)) + return None + + def getSetting(self, key): + if not self._current_user_settings: + self._load_user_settings() + if key in self._current_user_settings: + return self._current_user_settings[key].get("value") + return None + + def setSetting(self, key, value): + if not self._current_user_settings: + self._load_user_settings() + self._current_user_settings.update({key: {"value": str(value)}}) + + class xbmcplugin: + @staticmethod + def addDirectoryItem(handle, url, listitem, isFolder=False, totalItems=0): + MOCK.DIRECTORY.items.append((url, listitem, isFolder)) + + @staticmethod + def addDirectoryItems(handle, items, totalItems=0): + MOCK.DIRECTORY.items.extend(items) + + @staticmethod + def endOfDirectory( + handle, succeeded=True, updateListing=False, cacheToDisc=True + ): + #MOCK.DIRECTORY.handle_directory() + pass + + @staticmethod + def setContent(handle, content): + MOCK.DIRECTORY.content = content + + @staticmethod + def addSortMethod(handle, sortMethod, label2Mask=""): + MOCK.DIRECTORY.sort_method = sortMethod + + class xbmcgui: + class ListItem(xbmcgui.ListItem): + def __init__( + self, + label="", + label2="", + iconImage="", + thumbnailImage="", + path="", + offscreen=False, + ): + self.contentLookup = None + self._label = label + self._label2 = label2 + self._icon = iconImage + self._thumb = thumbnailImage + self._path = path + self._offscreen = offscreen + self._props = {} + self._selected = False + self.cm = [] + self.vitags = {} + self.art = {} + self.info = {} + self.info_type = "" + self.uniqueIDs = {} + self.ratings = {} + self.contentLookup = True + self.stream_info = {} + + def addContextMenuItems(self, items, replaceItems=False): + [self.cm.append(i) for i in items] + + def getLabel(self): + return self._label + + def getLabel2(self): + return self._label2 + + def getProperty(self, key): + key = key.lower() + if key in self._props: + return self._props[key] + return "" + + def isSelected(self): + return self._selected + + def select(self, selected): + self._selected = selected + + def setArt(self, values): + if not values: + return + self.art.update(values) + + def setIconImage(self, value): + self._icon = value + + def setInfo(self, type, infoLabels): + if type: + self.info_type = type + if isinstance(infoLabels, dict): + self.info.update(infoLabels) + + def setLabel(self, label): + self._label = label + + def setLabel2(self, label): + self._label2 = label + + def setProperty(self, key, value): + key = key.lower() + self._props[key] = value + + def setThumbnailImage(self, value): + self._thumb = value + + def setCast(self, actors): + """Set cast including thumbnails. Added in v17.0""" + pass + + def setUniqueIDs(self, ids, **kwargs): + self.uniqueIDs.update(ids) + + def setRating(self, rating_type, rating, votes=0, default=False): + self.ratings.update({rating_type: [rating, votes, default]}) + + def setContentLookup(self, enable): + self.contentLookup = enable + + def addStreamInfo(self, cType, dictionary): + self.stream_info.update({cType: dictionary}) + + def getPath(self): + return self._path + + def __str__(self): + return self._label + + class Window(xbmcgui.Window): + def __init__(self, windowId=0): + self._props = {} + + def clearProperties(self): + self._props.clear() + + def clearProperty(self, key): + key = key.lower() + if key in self._props: + del self._props[key] + + def getProperty(self, key): + key = key.lower() + if key in self._props: + return self._props[key] + return None + + def setProperty(self, key, value): + key = key.lower() + self._props[key] = value + + class Dialog(xbmcgui.Dialog): + def notification( + self, + heading, + message, + icon=xbmcgui.NOTIFICATION_INFO, + time=5000, + sound=True, + ): + if icon == xbmcgui.NOTIFICATION_WARNING: + prefix = "[WARNING]" + elif icon == xbmcgui.NOTIFICATION_ERROR: + prefix = "[ERROR]" + else: + prefix = "[INFO]" + print("NOTIFICATION: {0} {1}: {2}".format(prefix, heading, message)) + + def ok(self, heading, message): + print("{}: \n{}".format(heading, message)) + return True + + def select( + self, heading, list, autoclose=False, preselect=None, useDetails=False + ): + print(heading) + action = None + for idx, i in enumerate(list): + print("{}) {}".format(idx, i)) + while True: + try: + action = int(get_input()) + except: + break + if 0 <= action < len(list): + break + if action is None: + return -1 + print(list[action]) + return action + + def textviewer(self, heading, text, usemono=False): + print(heading) + print(text) + + def yesno( + self, + heading, + message, + nolabel="", + yeslabel="", + customlabel="", + autoclose=0, + ): + if not MOCK.INTERACTIVE_MODE: + return 1 + print("") + print("{}\n{}".format(heading, message)) + print("1) {}/ 0) {}".format(yeslabel, nolabel)) + action = get_input() + return action + + def input( + self, + heading, + defaultt="", + type=xbmcgui.INPUT_ALPHANUM, + option=None, + autoclose=None + ): + print(heading) + while True: + if type==xbmcgui.INPUT_ALPHANUM: + return get_input("Enter AlphaNum:") + elif type==xbmcgui.INPUT_NUMERIC: + try: + return float(get_input("Enter Number:")) + except: + pass + elif type==xbmcgui.INPUT_DATE: + date = get_input("Enter Date (DD/MM/YYYY):") + #TODO: + return date + elif type==xbmcgui.INPUT_TIME: + time = get_input("Enter Time (HH:MM):") + #TODO: + return time + elif type==xbmcgui.INPUT_IPADDRESS: + ip = get_input("Enter IP Address (#.#.#.#):") + #TODO: + return ip + elif type == xbmcgui.INPUT_PASSWORD: + return get_input("Enter Password:") + # TODO: needs to be md5 hashed, and optionally verified + + + + class DialogBusy: + """Show/Hide the progress indicator. Added in v17.0""" + + def create(self): + print("[BUSY] show") + + def update(self, percent): + print("[BUSY] update: {0}".format(percent)) + + def close(self): + print("[BUSY] close") + + def iscanceled(self): + return False + + class DialogProgress(xbmcgui.DialogProgress): + canceled = False + + def __init__(self): + self._created = False + self._heading = None + self._message = None + self._percent = -1 + + def update(self, percent, message=""): + if percent: + self._percent = percent + if message: + self._message = message + print( + "[PROGRESS] {0}: {1} - {2}%".format( + self._heading, self._message, self._percent + ) + ) + + def create(self, heading, message=""): + self._created = True + self._heading = heading + self._message = message + self._percent = 0 + print( + "[PROGRESS] {0}: {1} - {2}%".format( + self._heading, self._message, self._percent + ) + ) + + def iscanceled(self): + return self.canceled + + def close(self): + print("[PROGRESS] closing") + + class DialogProgressBG(xbmcgui.DialogProgressBG): + def __init__(self): + self._created = False + self._heading = "" + self._message = "" + self._percent = 0 + + def create(self, heading, message=""): + self._created = True + self._heading = heading + self._message = message + self._percent = 0 + print( + "[BACKGROUND] {0}: {1} - {2}%".format( + self._heading, self._message, self._percent + ) + ) + + def close(self): + self._created = False + print("[BACKGROUND] closing") + + def update(self, percent=0, heading="", message=""): + self._percent = percent + if heading: + self._heading = heading + if message: + self._message = message + print( + "[BACKGROUND] {0}: {1} - {2}%".format( + self._heading, self._message, self._percent + ) + ) + + def isFinished(self): + return not self._created + + class xbmcvfs: + @staticmethod + def open(filepath, mode="r"): + if sys.version_info.major == 3: + return open(filepath, mode, encoding="utf-8") + else: + return open(filepath, mode) + + +class MonkeyPatchKodiStub: + """Helper class for Monkey patching kodistubs to add functionality.""" + + def __init__(self): + self._dict = SerenStubs.create_stubs() + + def trace_log(self): + self._walk_kodi_dependencies(self._trace_log_decorator) + + def monkey_patch(self): + self._walk_kodi_dependencies(self._monkey_patch) + + def _walk_kodi_dependencies(self, func): + [ + self._walk_item(i, func) + for i in [xbmc, xbmcgui, xbmcaddon, xbmcdrm, xbmcplugin, xbmcvfs] + ] + + def _walk_item(self, item, func, path=None): + if path is None: + path = [] + path.append(item.__name__) + for k, v in vars(item).items(): + if isinstance(v, (types.FunctionType, staticmethod)): + result = func(path, v) + if result: + setattr(item, k, result) + if type(v) is type: + result = func(path, v) + if result: + setattr(item, k, result) + else: + self._walk_item(v, func, path) + path.pop(-1) + + @staticmethod + def _trace_log_decorator(path, func): + """Add trace logging to the function it decorates. + :param func: Function to decorate + :type func: types.FunctionType + :return: Wrapped function + :rtype: types.FunctionType + """ + joined_path = ".".join(path) + + @functools.wraps(func) + def _wrapped(*args, **kwargs): + try: + if args: + print( + "Entering: {}.{} with parameters {}".format( + joined_path, func.__name__, args + ) + ) + else: + print("Entering: {}.{}".format(joined_path, func.__name__)) + try: + return func(*args, **kwargs) + except Exception as e: + print( + "Exception in {}.{} : {}".format(joined_path, func.__name__, e) + ) + raise e + finally: + print("Exiting: {}.{}".format(joined_path, func.__name__)) + + return _wrapped + + @staticmethod + def _decorate(func, patch): + @functools.wraps(func) + def _wrapped(*args, **kwargs): + return patch(func(*args, **kwargs)) + + return _wrapped + + def _monkey_patch(self, path, item): + patch = None + for p in path: + if patch: + patch = patch.get(p, {}) + else: + patch = self._dict.get(p, {}) + patch = patch.get(item.__name__) + if patch: + return patch + elif isinstance(item, types.FunctionType): + return self._log_not_patched_method(path, item) + + @staticmethod + def _log_not_patched_method(path, func): + """Add logging to the function that indicates that there is not mockey patch available. + :param path: path of the calling method + :type path: list[string] + :param func: Function to decorate + :type func: types.FunctionType + :return: Wrapped function + :rtype: types.FunctionType + """ + joined_path = ".".join(path) + + @functools.wraps(func) + def _wrapped(*args, **kwargs): + object_type = "method" if isinstance(func, types.FunctionType) else "object" + print( + "Call to not patched {}: {}.{}".format( + object_type, joined_path, func.__name__ + ) + ) + return func(*args, **kwargs) + + return _wrapped + + +class MockKodi: + """KODIStub mock helper""" + + def __init__(self): + self.XBMC_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__))) + self.PROFILE_ROOT = os.path.abspath(os.path.join(self.XBMC_ROOT, "../")) + self.SEREN_ROOT = self.PROFILE_ROOT + self.KODI_UI_LANGUAGE = os.environ.get("KODI_UI_LANGUAGE", "en-gb") + self.INTERACTIVE_MODE = ( + os.environ.get("SEREN_INTERACTIVE_MODE", False) == "True" + ) + self.RUN_AGAINST_INSTALLATION = ( + os.environ.get("SEREN_RUN_AGAINST_INSTALLATION", False) == "True" + ) + if self.RUN_AGAINST_INSTALLATION and os.path.exists( + self.get_kodi_installation() + ): + self.PROFILE_ROOT = self.get_kodi_installation() + self.SEREN_ROOT = os.path.join( + self.PROFILE_ROOT, "addons", PLUGIN_NAME + ) + + self.DIRECTORY = Directory() + self.LOG_HISTORY = [] + self._monkey_patcher = MonkeyPatchKodiStub() + # self._monkey_patcher.trace_log() + self._monkey_patcher.monkey_patch() + + @staticmethod + def get_kodi_installation(): + """ + :return: + :rtype: + """ + dir_home = os.path.expanduser("~") + if sys.platform == "win32": + return os.path.join(dir_home, "AppData", "Roaming", "Kodi") + return os.path.join(dir_home, ".kodi") + + +MOCK = MockKodi() + + +class JsonEncoder(json.JSONEncoder): + """Json encoder for serialising all objects""" + + def default(self, o): + """ + :param o: + :type o: + :return: + :rtype: + """ + return o.__dict__ + + +def kodi_to_ansi(string): + """ + :param string: + :type string: + :return: + :rtype: + """ + if string is None: + return None + string = string.replace("[B]", "\033[1m") + string = string.replace("[/B]", "\033[21m") + string = string.replace("[I]", "\033[3m") + string = string.replace("[/I]", "\033[23m") + string = string.replace("[COLOR gray]", "\033[30;1m") + string = string.replace("[COLOR red]", "\033[31m") + string = string.replace("[COLOR green]", "\033[32m") + string = string.replace("[COLOR yellow]", "\033[33m") + string = string.replace("[COLOR blue]", "\033[34m") + string = string.replace("[COLOR purple]", "\033[35m") + string = string.replace("[COLOR cyan]", "\033[36m") + string = string.replace("[COLOR white]", "\033[37m") + string = string.replace("[/COLOR]", "\033[39;0m") + return string + + +class MockKodiUILanguage(object): + def __init__(self, new_language): + self.new_language = new_language + self.original_language = MOCK.KODI_UI_LANGUAGE + + def __enter__(self): + MOCK.KODI_UI_LANGUAGE = self.new_language + return self.new_language + + def __exit__(self, exc_type, exc_val, exc_tb): + MOCK.KODI_UI_LANGUAGE = self.original_language \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..b50d6b6f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,24 @@ +certifi==2020.4.5.1 +chardet==3.0.4 +idna==2.9 +requests==2.23.0 +urllib3==1.25.9 +pytz +polib~=1.1.0 +future~=0.17.1 +bs4 +pytest +vcrpy +pylint +coverage +flake8 +flake8-docstrings +parameterized +darglint; python_version>"3.5" +kodistubs==18.0.0; python_version<"3.7" +kodistubs==19.0.1; python_version>="3.7" +contextlib2; python_version<"3" +requests-mock +Mock +tzlocal +Pillow From 92b12d585da8af4db54fafb3efd6cdfffe74290e Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Mon, 1 Mar 2021 21:53:06 +0700 Subject: [PATCH 02/23] fixes to make work with cli. po and xml syntax errors. remove kodi_six. --- .../resources/language/resource.language.en_gb/strings.po | 6 ++---- plugin.program.autowidget/resources/lib/common/directory.py | 5 +++-- plugin.program.autowidget/resources/lib/common/router.py | 4 ++++ plugin.program.autowidget/resources/lib/common/utils.py | 6 +++--- plugin.program.autowidget/resources/lib/menu.py | 2 +- plugin.program.autowidget/resources/settings.xml | 6 +++--- 6 files changed, 16 insertions(+), 13 deletions(-) diff --git a/plugin.program.autowidget/resources/language/resource.language.en_gb/strings.po b/plugin.program.autowidget/resources/language/resource.language.en_gb/strings.po index 623e2f71..8a8f9358 100644 --- a/plugin.program.autowidget/resources/language/resource.language.en_gb/strings.po +++ b/plugin.program.autowidget/resources/language/resource.language.en_gb/strings.po @@ -16,8 +16,6 @@ msgstr "" "Language: en\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#This is a comment - msgctxt "#32000" msgid "General" msgstr "" @@ -95,7 +93,7 @@ msgid "Create a new group of shortcuts." msgstr "" msgctxt "#32019" -msgid "View the "{}" group." +msgid "View the \"{}\" group." msgstr "" msgctxt "#32020" @@ -563,7 +561,7 @@ msgid "Predefined Color" msgstr "" msgctxt "#32136" -msgid "Hex codes must be of the format "#RRGGBB"." +msgid "Hex codes must be of the format \"#RRGGBB\"." msgstr "" msgctxt "#32137" diff --git a/plugin.program.autowidget/resources/lib/common/directory.py b/plugin.program.autowidget/resources/lib/common/directory.py index 20edf705..5e30a77a 100644 --- a/plugin.program.autowidget/resources/lib/common/directory.py +++ b/plugin.program.autowidget/resources/lib/common/directory.py @@ -6,6 +6,7 @@ import six from resources.lib.common import utils +from resources.lib import common try: from urllib.parse import urlencode @@ -60,8 +61,8 @@ def add_sort_methods(handle): def add_menu_item(title, params=None, path=None, info=None, cm=None, art=None, isFolder=False, props=None): - _plugin = sys.argv[0] - _handle = int(sys.argv[1]) + _plugin = common.dispatched_plugin + _handle = common.dispatched_handle if params is not None: diff --git a/plugin.program.autowidget/resources/lib/common/router.py b/plugin.program.autowidget/resources/lib/common/router.py index 7579078a..2121d7b2 100644 --- a/plugin.program.autowidget/resources/lib/common/router.py +++ b/plugin.program.autowidget/resources/lib/common/router.py @@ -10,6 +10,7 @@ from resources.lib import refresh from resources.lib.common import directory from resources.lib.common import utils +from resources.lib import common def _log_params(_plugin, _handle, _params): @@ -27,6 +28,9 @@ def _log_params(_plugin, _handle, _params): def dispatch(_plugin, _handle, _params): + common.dispatched_plugin = _plugin + common.dispatched_handle = _handle + params = _log_params(_plugin, int(_handle), _params) category = 'AutoWidget' is_dir = False diff --git a/plugin.program.autowidget/resources/lib/common/utils.py b/plugin.program.autowidget/resources/lib/common/utils.py index e6dfab72..451e69ed 100644 --- a/plugin.program.autowidget/resources/lib/common/utils.py +++ b/plugin.program.autowidget/resources/lib/common/utils.py @@ -1,6 +1,6 @@ -from kodi_six import xbmc -from kodi_six import xbmcaddon -from kodi_six import xbmcgui +import xbmc +import xbmcaddon +import xbmcgui import codecs import contextlib diff --git a/plugin.program.autowidget/resources/lib/menu.py b/plugin.program.autowidget/resources/lib/menu.py index ef3d2dfc..885528fa 100644 --- a/plugin.program.autowidget/resources/lib/menu.py +++ b/plugin.program.autowidget/resources/lib/menu.py @@ -1,4 +1,4 @@ -from kodi_six import xbmcgui +import xbmcgui import re import uuid diff --git a/plugin.program.autowidget/resources/settings.xml b/plugin.program.autowidget/resources/settings.xml index ffea9a60..056cca3b 100644 --- a/plugin.program.autowidget/resources/settings.xml +++ b/plugin.program.autowidget/resources/settings.xml @@ -37,9 +37,9 @@ - - - + + + From 969c77533f112c2bf64e0c07300ef827771e55b1 Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Wed, 3 Mar 2021 00:54:31 +0700 Subject: [PATCH 03/23] start of doctest --- plugin.program.autowidget/cli.py | 60 ++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/plugin.program.autowidget/cli.py b/plugin.program.autowidget/cli.py index 49a9d8ca..858eba28 100644 --- a/plugin.program.autowidget/cli.py +++ b/plugin.program.autowidget/cli.py @@ -9,6 +9,8 @@ import runpy from urllib.parse import urlparse import sys +import doctest +import time def execute_callback(): @@ -79,7 +81,7 @@ def home(path): def dummy_folder(path): for i in range(1,20): - p = "plugin://dummy/?item={}".format(i) + p = "plugin://dummy/item{}".format(i) xbmcplugin.addDirectoryItem( handle=1, url=p, @@ -88,10 +90,62 @@ def dummy_folder(path): ) xbmcplugin.endOfDirectory(handle=1) MOCK.DIRECTORY.register_action("plugin://dummy", dummy_folder) + #t = threading.Thread(target=start_service).start() + +def press(keys): + MOCK.INPUT_QUEUE.put(keys) + MOCK.INPUT_QUEUE.join() + +def test_add_widget_group(): + """ + >>> t = threading.Thread(target=MOCK.DIRECTORY.handle_directory, daemon = True) + >>> t.start(); time.sleep(1) + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) AutoWidget + 2) Dummy + + Enter Action Number + + >>> press("c2") + 1) Add to AutoWidget Group + + + >>> press(1) + Add as + 0) Shortcut + 1) Widget + 2) Clone as Shortcut Group + 3) Explode as Widget Group + 4) Settings Shortcut + + >>> press(1) + Widget + Choose a Group + 0) Create New Widget Group + >>> press(0) + Create New Widget Group + Name for Group + + >>> press("Widget1") + Choose a Group + 0) Create New Widget Group + 1) Widget1 + + >>> press(1) + Widget1 + Widget Label + + >>> press("My Label") + + """ if __name__ == '__main__': os.environ['SEREN_INTERACTIVE_MODE'] = 'True' - #t = threading.Thread(target=start_service).start() setup() - MOCK.DIRECTORY.handle_directory() + doctest.testmod() + #threading.Thread(target=MOCK.DIRECTORY.handle_directory).start() + #MOCK.INPUT_QUEUE=[2,"c1",1,1,0,"Widget1",1,"Widget1",0,1,1] From 03a4ad1185719ff6eccf63b94a32ae15188e84c4 Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Wed, 3 Mar 2021 00:54:55 +0700 Subject: [PATCH 04/23] basic infolabels and input queue for testing prepared input --- .../mock_kodi/__init__.py | 92 +++++++++++++++---- 1 file changed, 72 insertions(+), 20 deletions(-) diff --git a/plugin.program.autowidget/mock_kodi/__init__.py b/plugin.program.autowidget/mock_kodi/__init__.py index 44f2c0e1..f68b1598 100644 --- a/plugin.program.autowidget/mock_kodi/__init__.py +++ b/plugin.program.autowidget/mock_kodi/__init__.py @@ -11,6 +11,7 @@ import types import runpy from urllib.parse import urlparse +import queue import polib @@ -34,12 +35,6 @@ PYTHON3 = True if sys.version_info.major == 3 else False PYTHON2 = not PYTHON3 - -if PYTHON2: - get_input = raw_input # noqa -else: - get_input = input - SUPPORTED_LANGUAGES = { "en-de": ("en-de", "eng-deu", "English-Central Europe"), "en-aus": ("en-aus", "eng-aus", "English-Australia (12h)"), @@ -51,6 +46,21 @@ PLUGIN_NAME = "plugin.program.autowidget" +def get_input(prompt=""): + if MOCK.INPUT_QUEUE: + if MOCK.INPUT_QUEUE.unfinished_tasks: + MOCK.INPUT_QUEUE.task_done() + keys = str(MOCK.INPUT_QUEUE.get(True)) + #print(f"{prompt}{keys}") + return keys + else: + if PYTHON2: + return raw_input(prompt) # noqa + else: + return input(prompt) + + + def makedirs(name, mode=0o777, exist_ok=False): """makedirs(name [, mode=0o777][, exist_ok=False]) Super-mkdir; create a leaf directory and all intermediate ones. Works like @@ -82,8 +92,7 @@ def __init__(self): last_action = "" next_action = "" current_list_item = None - current_container_item = None - content = {} + content = "movies" sort_method = {} action_callbacks = {} context_callbacks = [] @@ -230,16 +239,21 @@ def get_items_dictionary(self): self.items = [] return result - def getInfoLabel(self, value): + def executeInfoLabel(self, value): # handle ListItem or Container infolabels ListItem = self.current_list_item - Container = self.current_container_item - value = value.replace(".", ".get") - res = eval(value, locals()) - if callable(res): - return res() + Container = self + #value = value.replace(".", ".get") + v2 = re.sub(r"\.([^\d\W]\w*)\(([.\w]*)\)", r".getInfoLabel('\1','\2')", value) # ListItem.Art(banner) + if v2 == value: # HACK + v2 = re.sub(r"\.([^\d\W]\w*)([^\(]?)", r".getInfoLabel('\1')\2", value) # ListItem.Art + return eval(v2, locals()) + + def getInfoLabel(self, key): + if key == 'Content': + return self.content else: - return res + raise Exception(f"Not found {key}") def register_action(self, path, script): @@ -377,10 +391,10 @@ def getInfoLabel(value): if PYTHON3: return "19" elif value.startswith("ListItem.") or value.startswith("Container."): - res = MOCK.DIRECTORY.getInfoLabel(value) + res = MOCK.DIRECTORY.executeInfoLabel(value) if res is not None: return res - print("Couldn't find the infolabel") + print(f"Couldn't find the infolabel: {value}") return "" @staticmethod @@ -449,6 +463,17 @@ def executebuiltin(function, wait=False): """Execute a built in Kodi function""" print("EXECUTE BUILTIN: {} wait:{}".format(function, wait)) + @staticmethod + def executeJSONRPC(jsonrpccommand): + command = json.loads(jsonrpccommand) + method = command.get("method") + if method == 'JSONRPC.Version': + res = dict(result=dict(version=[19,0,0])) + else: + raise Exception(f"executeJSONRPC not handled for {method}") + return json.dumps(res) + + class PlayList(xbmc.PlayList): def __init__(self, playList): self.list = [] @@ -678,6 +703,7 @@ def __init__( self.cm = [] self.vitags = {} self.art = {} + self.votes = {} self.info = {} self.info_type = "" self.uniqueIDs = {} @@ -700,6 +726,21 @@ def getProperty(self, key): return self._props[key] return "" + def getRating(self, key=None): #make key optional for getInfoLabel + return self.ratings.get(key, 0.0) if key else 0.0 + + def getArt(self, key='thumb'): + return self.art.get(key, "") + + def getPath(self): + return self._path + + def getVotes(self, key=None): + if key is None: + return self.votes.values[0] if self.votes else 0 + else: + return self.votes.get(key, 0) + def isSelected(self): return self._selected @@ -749,12 +790,22 @@ def setContentLookup(self, enable): def addStreamInfo(self, cType, dictionary): self.stream_info.update({cType: dictionary}) - def getPath(self): - return self._path - def __str__(self): return self._label + # Additional methods for infolabels + + def getInfoLabel(self, key, *params): + # return value or function + if key in self.info: + return self.info[key] + if hasattr(self, 'get'+key): + return getattr(self, 'get'+key)(*params) + + + def getFolderPath(self): + return self._path + class Window(xbmcgui.Window): def __init__(self, windowId=0): self._props = {} @@ -1109,6 +1160,7 @@ def __init__(self): self.DIRECTORY = Directory() self.LOG_HISTORY = [] + self.INPUT_QUEUE = queue.Queue() self._monkey_patcher = MonkeyPatchKodiStub() # self._monkey_patcher.trace_log() self._monkey_patcher.monkey_patch() From 407fd55e994fef83b7928db4f21a79ddb863bd1d Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Wed, 3 Mar 2021 08:53:09 +0700 Subject: [PATCH 05/23] start of handling condition visibility --- plugin.program.autowidget/cli.py | 15 +++++-- .../mock_kodi/__init__.py | 44 ++++++++++++++----- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/plugin.program.autowidget/cli.py b/plugin.program.autowidget/cli.py index 858eba28..49acb2a8 100644 --- a/plugin.program.autowidget/cli.py +++ b/plugin.program.autowidget/cli.py @@ -119,7 +119,6 @@ def test_add_widget_group(): 1) Widget 2) Clone as Shortcut Group 3) Explode as Widget Group - 4) Settings Shortcut >>> press(1) Widget @@ -140,6 +139,18 @@ def test_add_widget_group(): Widget Label >>> press("My Label") + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) AutoWidget + 2) Dummy + + Enter Action Number + + Ensure the Widget is there + >>> press(1) + """ @@ -147,5 +158,3 @@ def test_add_widget_group(): os.environ['SEREN_INTERACTIVE_MODE'] = 'True' setup() doctest.testmod() - #threading.Thread(target=MOCK.DIRECTORY.handle_directory).start() - #MOCK.INPUT_QUEUE=[2,"c1",1,1,0,"Widget1",1,"Widget1",0,1,1] diff --git a/plugin.program.autowidget/mock_kodi/__init__.py b/plugin.program.autowidget/mock_kodi/__init__.py index f68b1598..bf8b8123 100644 --- a/plugin.program.autowidget/mock_kodi/__init__.py +++ b/plugin.program.autowidget/mock_kodi/__init__.py @@ -243,15 +243,25 @@ def executeInfoLabel(self, value): # handle ListItem or Container infolabels ListItem = self.current_list_item Container = self + class Window: + @staticmethod + def getInfoLabel(key, *params): + if key == 'IsMedia': + return False #value = value.replace(".", ".get") - v2 = re.sub(r"\.([^\d\W]\w*)\(([.\w]*)\)", r".getInfoLabel('\1','\2')", value) # ListItem.Art(banner) - if v2 == value: # HACK - v2 = re.sub(r"\.([^\d\W]\w*)([^\(]?)", r".getInfoLabel('\1')\2", value) # ListItem.Art + v1 = re.sub(r"\.([^\d\W]\w*)\(([.\w]*)\)", r".getInfoLabel('\1','\2')", value) # ListItem.Art(banner) + v2 = re.sub(r"\.(?!getInfoLabel)([^\d\W]\w*)(?!\()", r".getInfoLabel('\1')", v1) # ListItem.Art return eval(v2, locals()) + + @property + def ListItem(self): + return self.current_list_item def getInfoLabel(self, key): if key == 'Content': return self.content + elif key == 'ListItem': + return self.current_list_item else: raise Exception(f"Not found {key}") @@ -397,6 +407,17 @@ def getInfoLabel(value): print(f"Couldn't find the infolabel: {value}") return "" + @staticmethod + def getCondVisibility(value): + if value == "Window.IsMedia": + return 0 + res = MOCK.DIRECTORY.executeInfoLabel(value) + if res is not None: + return res + print(f"Couldn't find condition: {value}") + + + @staticmethod def getSupportedMedia(media): """Returns the supported file types for the specific media as a string""" @@ -444,11 +465,6 @@ def log(msg, level=xbmc.LOGDEBUG): print(value) MOCK.LOG_HISTORY.append(value) - @staticmethod - def getCondVisibility(value): - if value == "Window.IsMedia": - return 0 - @staticmethod def getLanguage(format=xbmc.ENGLISH_NAME, region=False): """Returns the active language as a string.""" @@ -659,6 +675,7 @@ def setSetting(self, key, value): class xbmcplugin: @staticmethod def addDirectoryItem(handle, url, listitem, isFolder=False, totalItems=0): + listitem.is_folder = isFolder MOCK.DIRECTORY.items.append((url, listitem, isFolder)) @staticmethod @@ -710,6 +727,7 @@ def __init__( self.ratings = {} self.contentLookup = True self.stream_info = {} + self.is_folder = False def addContextMenuItems(self, items, replaceItems=False): [self.cm.append(i) for i in items] @@ -797,15 +815,19 @@ def __str__(self): def getInfoLabel(self, key, *params): # return value or function - if key in self.info: - return self.info[key] if hasattr(self, 'get'+key): return getattr(self, 'get'+key)(*params) - + if key in self.info: + return self.info[key] + else: + return "" # HACK def getFolderPath(self): return self._path + def getIsFolder(self): + return self.is_folder + class Window(xbmcgui.Window): def __init__(self, windowId=0): self._props = {} From 4998aab72b6da0c724652f96fee4ef09e898e2a8 Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Wed, 3 Mar 2021 12:48:11 +0700 Subject: [PATCH 06/23] doing an action returns to existing menu if another isn't created --- plugin.program.autowidget/cli.py | 14 ++++++++++---- plugin.program.autowidget/mock_kodi/__init__.py | 8 +++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/plugin.program.autowidget/cli.py b/plugin.program.autowidget/cli.py index 49acb2a8..9c5bbfd5 100644 --- a/plugin.program.autowidget/cli.py +++ b/plugin.program.autowidget/cli.py @@ -90,16 +90,22 @@ def dummy_folder(path): ) xbmcplugin.endOfDirectory(handle=1) MOCK.DIRECTORY.register_action("plugin://dummy", dummy_folder) - #t = threading.Thread(target=start_service).start() def press(keys): MOCK.INPUT_QUEUE.put(keys) - MOCK.INPUT_QUEUE.join() + MOCK.INPUT_QUEUE.join() # wait until the action got processed (ie until we wait for more input) + +def start_kodi(service=True): + threading.Thread(target=MOCK.DIRECTORY.handle_directory, daemon = True).start() + time.sleep(0.1) # give the home menu enough time to output + if service: + service = threading.Thread(target=start_service, daemon = True).start() + time.sleep(1) # give the home menu enough time to output + def test_add_widget_group(): """ - >>> t = threading.Thread(target=MOCK.DIRECTORY.handle_directory, daemon = True) - >>> t.start(); time.sleep(1) + >>> start_kodi(service=False) ------------------------------- -1) Back 0) Home diff --git a/plugin.program.autowidget/mock_kodi/__init__.py b/plugin.program.autowidget/mock_kodi/__init__.py index bf8b8123..4a7eecdf 100644 --- a/plugin.program.autowidget/mock_kodi/__init__.py +++ b/plugin.program.autowidget/mock_kodi/__init__.py @@ -106,10 +106,13 @@ def handle_directory(self): return while True: + old_items = self.items self.items = [] self._execute_action() - self.current_container_item = self.current_list_item - self.current_list_item = None + if not self.items: # plugin didn't open a menu, keep on teh same one + self.items = old_items + else: + self.current_list_item = None while True: @@ -135,7 +138,6 @@ def handle_directory(self): break else: print("Please enter a valid entry") - time.sleep(1) # old_items = self.items # self.items = [] From ef8ab085adc9fbd646d212e15ca758bb591bd38f Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Wed, 3 Mar 2021 21:50:12 +0700 Subject: [PATCH 07/23] reset state on setup --- plugin.program.autowidget/cli.py | 15 ++++++++++----- plugin.program.autowidget/mock_kodi/__init__.py | 17 ++++++----------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/plugin.program.autowidget/cli.py b/plugin.program.autowidget/cli.py index 9c5bbfd5..d9b50177 100644 --- a/plugin.program.autowidget/cli.py +++ b/plugin.program.autowidget/cli.py @@ -21,16 +21,20 @@ def start_service(): _monitor = refresh.RefreshService() _monitor.waitForAbort() +def teardown(): + import shutil + shutil.rmtree(MOCK.PROFILE_ROOT) + def setup(): - #item.setInfo('video', def_info) - #item.setMimeType(def_info.get('mimetype', '')) - #item.setArt(def_art) - #item.addContextMenuItems(def_cm) + import tempfile + MOCK.PROFILE_ROOT = tempfile.mkdtemp() +# makedirs(os.environ['KODI_PROFILE_ROOT'], exist_ok=True) + _addon = xbmcaddon.Addon() # create dirs _addon_id = _addon.getAddonInfo('id') _addon_path = xbmc.translatePath(_addon.getAddonInfo('profile')) - _addon_root = xbmc.translatePath(_addon.getAddonInfo('path')) + #_addon_root = xbmc.translatePath(_addon.getAddonInfo('path')) makedirs(_addon_path, exist_ok=True) @@ -164,3 +168,4 @@ def test_add_widget_group(): os.environ['SEREN_INTERACTIVE_MODE'] = 'True' setup() doctest.testmod() + teardown() diff --git a/plugin.program.autowidget/mock_kodi/__init__.py b/plugin.program.autowidget/mock_kodi/__init__.py index 4a7eecdf..0144434d 100644 --- a/plugin.program.autowidget/mock_kodi/__init__.py +++ b/plugin.program.autowidget/mock_kodi/__init__.py @@ -139,14 +139,6 @@ def handle_directory(self): else: print("Please enter a valid entry") - # old_items = self.items - # self.items = [] - # self._execute_action() - # if not self.items: - # self.items = old_items - # self._try_handle_menu_action(-1) - # TODO: action that doesn't make a menu - def _try_handle_menu_action(self, action): try: action = int(action) - 1 @@ -597,7 +589,7 @@ def _load_user_settings(self): def _init_user_settings(self): settings_def_file = os.path.join( os.path.join( - MOCK.PROFILE_ROOT, + MOCK.SEREN_ROOT, "resources", "settings.xml", ) @@ -1164,9 +1156,12 @@ class MockKodi: """KODIStub mock helper""" def __init__(self): - self.XBMC_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__))) + here = os.path.abspath(os.path.join(os.path.dirname(__file__))) + self.XBMC_ROOT = here + #self.XBMC_ROOT = os.environ.get("KODI_ROOT", self.XBMC_ROOT) self.PROFILE_ROOT = os.path.abspath(os.path.join(self.XBMC_ROOT, "../")) - self.SEREN_ROOT = self.PROFILE_ROOT + self.PROFILE_ROOT = os.environ.get("KODI_PROFILE_ROOT", self.PROFILE_ROOT) + self.SEREN_ROOT = os.path.abspath(os.path.join(here, "../")) self.KODI_UI_LANGUAGE = os.environ.get("KODI_UI_LANGUAGE", "en-gb") self.INTERACTIVE_MODE = ( os.environ.get("SEREN_INTERACTIVE_MODE", False) == "True" From e7659c48715a83d0d3897fe70c28e84834424caf Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Wed, 3 Mar 2021 23:34:58 +0700 Subject: [PATCH 08/23] able to use label names in menus --- plugin.program.autowidget/cli.py | 36 ++++----- .../mock_kodi/__init__.py | 81 ++++++++++++------- 2 files changed, 70 insertions(+), 47 deletions(-) diff --git a/plugin.program.autowidget/cli.py b/plugin.program.autowidget/cli.py index d9b50177..6d280efd 100644 --- a/plugin.program.autowidget/cli.py +++ b/plugin.program.autowidget/cli.py @@ -28,19 +28,8 @@ def teardown(): def setup(): import tempfile MOCK.PROFILE_ROOT = tempfile.mkdtemp() -# makedirs(os.environ['KODI_PROFILE_ROOT'], exist_ok=True) _addon = xbmcaddon.Addon() - # create dirs - _addon_id = _addon.getAddonInfo('id') - _addon_path = xbmc.translatePath(_addon.getAddonInfo('profile')) - #_addon_root = xbmc.translatePath(_addon.getAddonInfo('path')) - makedirs(_addon_path, exist_ok=True) - - - - # load the context menus - #_addon._config # # @@ -116,26 +105,25 @@ def test_add_widget_group(): ------------------------------- 1) AutoWidget 2) Dummy - + ------------------------------- Enter Action Number >>> press("c2") 1) Add to AutoWidget Group - - >>> press(1) + >>> press("Add to AutoWidget Group") Add as 0) Shortcut 1) Widget 2) Clone as Shortcut Group 3) Explode as Widget Group - >>> press(1) + >>> press("Widget") Widget Choose a Group 0) Create New Widget Group - >>> press(0) + >>> press("Create New Widget Group") Create New Widget Group Name for Group @@ -144,7 +132,7 @@ def test_add_widget_group(): 0) Create New Widget Group 1) Widget1 - >>> press(1) + >>> press("Widget1") Widget1 Widget Label @@ -155,12 +143,20 @@ def test_add_widget_group(): ------------------------------- 1) AutoWidget 2) Dummy - + ------------------------------- Enter Action Number Ensure the Widget is there - >>> press(1) - + >>> press("AutoWidget") + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) My Groups + 2) Active Widgets + 3) Tools + ------------------------------- + >>> press("My Groups") """ diff --git a/plugin.program.autowidget/mock_kodi/__init__.py b/plugin.program.autowidget/mock_kodi/__init__.py index 0144434d..d5ce0a54 100644 --- a/plugin.program.autowidget/mock_kodi/__init__.py +++ b/plugin.program.autowidget/mock_kodi/__init__.py @@ -12,6 +12,7 @@ import runpy from urllib.parse import urlparse import queue +import doctest import polib @@ -81,6 +82,32 @@ def makedirs(name, mode=0o777, exist_ok=False): if not exist_ok: raise +def pick_item(selected, items, start=0): + """ + >>> abc = ["A","B","C"] + >>> pick_item("1", abc) + 0 + >>> pick_item("B", abc) + 1 + >>> pick_item("blah", abc) is None + True + >>> pick_item("5", abc) is None + True + >>> pick_item("0", abc, -1) + -1 + """ + + try: + action = int(selected) - 1 + except: + action = next((i for i,item in enumerate(items, start) if str(item)==selected), None) + if action is None: + return None + if not(start <= action < len(items)-start): + return None + return action + + class Directory: """Directory class to keep track of items added to the virtual directory of the mock""" @@ -127,7 +154,7 @@ def handle_directory(self): for idx, item in enumerate(self.items): print(" {}) {}".format(idx + 1, item[1])) - print("") + print("-------------------------------") print("Enter Action Number") action = get_input() if self._try_handle_menu_action(action): @@ -139,10 +166,9 @@ def handle_directory(self): else: print("Please enter a valid entry") - def _try_handle_menu_action(self, action): - try: - action = int(action) - 1 - except: + def _try_handle_menu_action(self, selected): + action = pick_item(selected, ["Back", "Home"]+[str(i[1]) for i in self.items], -2) + if action is None: return False if action == -2: if self.history: @@ -150,16 +176,14 @@ def _try_handle_menu_action(self, action): self.last_action = "" self.current_list_item = None return True - if action == -1: + elif action == -1: self.next_action = "" self.current_list_item = None return True - elif -1 < action < len(self.items): + else: self.next_action = self.items[action][0] self.current_list_item = self.items[action][1] return True - else: - return False def _try_handle_context_menu_action(self, action): get_context_check = re.findall(r"^c(\d*)", action) @@ -181,16 +205,12 @@ def _try_handle_context_menu_action(self, action): for idx, item in enumerate(items): print(" {}) {}".format(idx + 1, item[1])) - print("") action = get_input("Enter Context Menu: ") - try: - action = int(action) - 1 - except: - return False - if -1 < action < len(items): - self.next_action = items[action][0] - return True - return False + action = pick_item(action, ["Back", "Cancel"]+[str(i[1]) for i in items], -2) + if action is None: + return False + self.next_action = items[action][0] + return True return True return False @@ -594,6 +614,15 @@ def _init_user_settings(self): "settings.xml", ) ) + addon_dir = os.path.join( + os.path.join( + MOCK.PROFILE_ROOT, + "userdata", + "addon_data", + PLUGIN_NAME, + ) + ) + makedirs(addon_dir, exist_ok=True) current_settings_file = os.path.join( os.path.join( MOCK.PROFILE_ROOT, @@ -872,14 +901,9 @@ def select( action = None for idx, i in enumerate(list): print("{}) {}".format(idx, i)) - while True: - try: - action = int(get_input()) - except: - break - if 0 <= action < len(list): - break - if action is None: + while action is None: + action = pick_item(get_input(), ["Back", "Cancel"] + list, -2) + if action < 0: return -1 print(list[action]) return action @@ -1247,4 +1271,7 @@ def __enter__(self): return self.new_language def __exit__(self, exc_type, exc_val, exc_tb): - MOCK.KODI_UI_LANGUAGE = self.original_language \ No newline at end of file + MOCK.KODI_UI_LANGUAGE = self.original_language + +if __name__ == '__main__': + doctest.testmod() From 17479107925f05e90482db1c91ada6098674bbe6 Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Thu, 4 Mar 2021 00:21:44 +0700 Subject: [PATCH 09/23] start of json getdirectory --- plugin.program.autowidget/cli.py | 27 ++++++++++++- .../mock_kodi/__init__.py | 38 ++++++++++++------- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/plugin.program.autowidget/cli.py b/plugin.program.autowidget/cli.py index 6d280efd..07acd4b4 100644 --- a/plugin.program.autowidget/cli.py +++ b/plugin.program.autowidget/cli.py @@ -146,7 +146,6 @@ def test_add_widget_group(): ------------------------------- Enter Action Number - Ensure the Widget is there >>> press("AutoWidget") ------------------------------- -1) Back @@ -156,8 +155,34 @@ def test_add_widget_group(): 2) Active Widgets 3) Tools ------------------------------- + >>> press("My Groups") + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) Widget1 + ------------------------------- + Enter Action Number + + >>> press("Widget1") + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) My Label + 2) Widget1 (Static) + 3) Widget1 (Cycling) + 4) Widget1 (Merged) + ------------------------------- + Enter Action Number + + >>> press("Widget1 (Cycling)") + Choose an Action + 0) Random Path + 1) Next Path + >>> press("Next Path") """ if __name__ == '__main__': diff --git a/plugin.program.autowidget/mock_kodi/__init__.py b/plugin.program.autowidget/mock_kodi/__init__.py index d5ce0a54..074e9e18 100644 --- a/plugin.program.autowidget/mock_kodi/__init__.py +++ b/plugin.program.autowidget/mock_kodi/__init__.py @@ -224,33 +224,36 @@ def _try_handle_action(self, action): print("Failed to parse action {}".format(action)) return False - def _execute_action(self): + def _execute_action(self, next_path=None): + if next_path is None: + next_path = self.next_action #from resources.lib.modules.globals import g #g.init_globals(["", 0, self.next_action]) for path,script in sorted(self.action_callbacks.items(), reverse=True): - if not self.next_action.startswith(path): + if not next_path.startswith(path): continue if type(script) == type(""): - p = urlparse(self.next_action) + p = urlparse(next_path) sys.argv = ["{}://{}".format(p.scheme, p.hostname), 1, "?"+p.query] runpy.run_module(script, run_name='__main__',) else: - script(self.next_action) + script(next_path) break - # for plugin,label,script in self.context_callbacks: - # if not self.next_action.startswith('context://{}/{}'.format(plugin,label)): - # continue - # callback(self.next_action) - - def get_items_dictionary(self): + def get_items_dictionary(self, path=None): """ :return: :rtype: """ - result = json.loads(json.dumps(self.items, cls=JsonEncoder)) - self.items = [] + if path is not None: + old_items = self.items + self._execute_action(path) + result = json.loads(json.dumps([i for _,i,_ in self.items], cls=JsonEncoder)) + if path is not None: + self.items = old_items + else: + self.items = [] return result def executeInfoLabel(self, value): @@ -307,6 +310,7 @@ def create_stubs(): "getLanguage": SerenStubs.xbmc.getLanguage, "getCondVisibility": SerenStubs.xbmc.getCondVisibility, "executebuiltin": SerenStubs.xbmc.executebuiltin, + "executeJSONRPC": SerenStubs.xbmc.executeJSONRPC, "PlayList": SerenStubs.xbmc.PlayList, "Monitor": SerenStubs.xbmc.Monitor, "validatePath": lambda t: t, @@ -498,7 +502,11 @@ def executeJSONRPC(jsonrpccommand): command = json.loads(jsonrpccommand) method = command.get("method") if method == 'JSONRPC.Version': - res = dict(result=dict(version=[19,0,0])) + res = dict(result=dict(version=dict(major=19,minor=0,patch=0))) + elif method == "Files.GetDirectory": + path = command['params']['directory'] + files = MOCK.DIRECTORY.get_items_dictionary(path) + res = dict(result=dict(files=files)) else: raise Exception(f"executeJSONRPC not handled for {method}") return json.dumps(res) @@ -812,6 +820,10 @@ def setProperty(self, key, value): key = key.lower() self._props[key] = value + def setProperties(self, properties): + for key, value in properties.items(): + self.setProperty(key, value) + def setThumbnailImage(self, value): self._thumb = value From 90003d37d68792a279123a72b0b6cc1f246e4acc Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Fri, 5 Mar 2021 01:49:08 +0700 Subject: [PATCH 10/23] fix GetDirectory RPC + other mocks --- plugin.program.autowidget/cli.py | 38 +++++++++--- .../mock_kodi/__init__.py | 61 ++++++++++++++++--- .../resources/lib/menu.py | 2 +- 3 files changed, 82 insertions(+), 19 deletions(-) diff --git a/plugin.program.autowidget/cli.py b/plugin.program.autowidget/cli.py index 07acd4b4..164ed149 100644 --- a/plugin.program.autowidget/cli.py +++ b/plugin.program.autowidget/cli.py @@ -56,17 +56,19 @@ def setup(): MOCK.DIRECTORY.register_action("plugin://plugin.program.autowidget", "main") def home(path): + url="plugin://plugin.program.autowidget/" xbmcplugin.addDirectoryItem( handle=1, - url="plugin://plugin.program.autowidget/", + url=url, listitem=xbmcgui.ListItem("AutoWidget"), isFolder=True ) - # add our fake plugin + # add our fake plugin + url="plugin://dummy/" xbmcplugin.addDirectoryItem( handle=1, - url="plugin://dummy/", - listitem=xbmcgui.ListItem("Dummy"), + url=url, + listitem=xbmcgui.ListItem("Dummy",path=url), isFolder=True ) xbmcplugin.endOfDirectory(handle=1) @@ -119,12 +121,10 @@ def test_add_widget_group(): 3) Explode as Widget Group >>> press("Widget") - Widget Choose a Group 0) Create New Widget Group >>> press("Create New Widget Group") - Create New Widget Group Name for Group >>> press("Widget1") @@ -133,7 +133,6 @@ def test_add_widget_group(): 1) Widget1 >>> press("Widget1") - Widget1 Widget Label >>> press("My Label") @@ -147,6 +146,7 @@ def test_add_widget_group(): Enter Action Number >>> press("AutoWidget") + LOGINFO - plugin.program.autowidget: [ root ] ------------------------------- -1) Back 0) Home @@ -155,8 +155,10 @@ def test_add_widget_group(): 2) Active Widgets 3) Tools ------------------------------- - + Enter Action Number + >>> press("My Groups") + LOGINFO - plugin.program.autowidget: [ mode: group ] ------------------------------- -1) Back 0) Home @@ -166,6 +168,7 @@ def test_add_widget_group(): Enter Action Number >>> press("Widget1") + LOGINFO - plugin.program.autowidget: [ mode: group ][ group: widget1-... ] ------------------------------- -1) Back 0) Home @@ -178,11 +181,28 @@ def test_add_widget_group(): Enter Action Number >>> press("Widget1 (Cycling)") + LOGINFO - plugin.program.autowidget: [ mode: path ][ action: cycling ][ group: widget1-... Choose an Action 0) Random Path 1) Next Path - >>> press("Next Path") + >>> press("Next Path") + LOGINFO - plugin.program.autowidget: Empty cache 0B (exp:-1 day, 23:..., last:0:00:00): ... ['...'] + LOGINFO - plugin.program.autowidget: Blocking cache path read: ... + LOGINFO - plugin.program.autowidget: Wrote cache ... (exp:0:02:.., last:0:00:00): ... ['...'] + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) Dummy Item 1 + 2) Dummy Item 2 + 3) Dummy Item 3 + 4) Dummy Item 4 + ... + 20) Dummy Item 20 + ------------------------------- + Enter Action Number + """ if __name__ == '__main__': diff --git a/plugin.program.autowidget/mock_kodi/__init__.py b/plugin.program.autowidget/mock_kodi/__init__.py index 074e9e18..54f464d4 100644 --- a/plugin.program.autowidget/mock_kodi/__init__.py +++ b/plugin.program.autowidget/mock_kodi/__init__.py @@ -47,6 +47,8 @@ PLUGIN_NAME = "plugin.program.autowidget" +LOG_LEVEL = "LOGINFO" + def get_input(prompt=""): if MOCK.INPUT_QUEUE: if MOCK.INPUT_QUEUE.unfinished_tasks: @@ -241,7 +243,7 @@ def _execute_action(self, next_path=None): script(next_path) break - def get_items_dictionary(self, path=None): + def get_items_dictionary(self, path=None, properties=[]): """ :return: :rtype: @@ -249,7 +251,7 @@ def get_items_dictionary(self, path=None): if path is not None: old_items = self.items self._execute_action(path) - result = json.loads(json.dumps([i for _,i,_ in self.items], cls=JsonEncoder)) + result = json.loads(json.dumps([i.toJSONRPC(properties) for _,i,_ in self.items], cls=JsonEncoder)) if path is not None: self.items = old_items else: @@ -263,8 +265,25 @@ def executeInfoLabel(self, value): class Window: @staticmethod def getInfoLabel(key, *params): - if key == 'IsMedia': - return False + params = [p for p in params if p] + a = getattr(Window, key) + if a: + return a(*params) + + @staticmethod + def IsActive(window): + if window=='home': + return self.next_action == "" + raise Exception(f"Not handled Window.IsActive({window})") + + @staticmethod + def Property(prop): + if prop == 'xmlfile': + return '' + + @staticmethod + def IsMedia(): + return False #value = value.replace(".", ".get") v1 = re.sub(r"\.([^\d\W]\w*)\(([.\w]*)\)", r".getInfoLabel('\1','\2')", value) # ListItem.Art(banner) v2 = re.sub(r"\.(?!getInfoLabel)([^\d\W]\w*)(?!\()", r".getInfoLabel('\1')", v1) # ListItem.Art @@ -331,6 +350,7 @@ def create_stubs(): "endOfDirectory": SerenStubs.xbmcplugin.endOfDirectory, "addSortMethod": SerenStubs.xbmcplugin.addSortMethod, "setContent": SerenStubs.xbmcplugin.setContent, + "setPluginCategory": SerenStubs.xbmcplugin.setPluginCategory, }, "xbmcvfs": { "File": SerenStubs.xbmcvfs.open, @@ -418,7 +438,7 @@ def getInfoLabel(value): return "18" if PYTHON3: return "19" - elif value.startswith("ListItem.") or value.startswith("Container."): + elif any(value.startswith(i) for i in ["ListItem.", "Container.", "Window."]): res = MOCK.DIRECTORY.executeInfoLabel(value) if res is not None: return res @@ -480,8 +500,9 @@ def log(msg, level=xbmc.LOGDEBUG): "LOGNONE", ] value = "{} - {}".format(levels[level], msg) - print(value) - MOCK.LOG_HISTORY.append(value) + if levels.index(LOG_LEVEL) <= level: + print(value) + MOCK.LOG_HISTORY.append(value) @staticmethod def getLanguage(format=xbmc.ENGLISH_NAME, region=False): @@ -505,7 +526,8 @@ def executeJSONRPC(jsonrpccommand): res = dict(result=dict(version=dict(major=19,minor=0,patch=0))) elif method == "Files.GetDirectory": path = command['params']['directory'] - files = MOCK.DIRECTORY.get_items_dictionary(path) + props = command['params'].get('properties',[]) + files = MOCK.DIRECTORY.get_items_dictionary(path, properties=props) res = dict(result=dict(files=files)) else: raise Exception(f"executeJSONRPC not handled for {method}") @@ -724,6 +746,11 @@ def endOfDirectory( def setContent(handle, content): MOCK.DIRECTORY.content = content + @staticmethod + def setPluginCategory(handle, category): + MOCK.DIRECTORY.content = category + + @staticmethod def addSortMethod(handle, sortMethod, label2Mask=""): MOCK.DIRECTORY.sort_method = sortMethod @@ -759,6 +786,7 @@ def __init__( self.contentLookup = True self.stream_info = {} self.is_folder = False + self.mimeType = '' def addContextMenuItems(self, items, replaceItems=False): [self.cm.append(i) for i in items] @@ -843,6 +871,9 @@ def setContentLookup(self, enable): def addStreamInfo(self, cType, dictionary): self.stream_info.update({cType: dictionary}) + def setMimeType(self, mimetype): + self._props['MimeType'] = mimetype + def __str__(self): return self._label @@ -863,6 +894,19 @@ def getFolderPath(self): def getIsFolder(self): return self.is_folder + def toJSONRPC(self, properties=[]): + item = dict( + filetype='direcotry' if self.is_folder else 'file', + title=self._label, + type="unknown", + file=self._path, + label=self._label, + art=self.art, + mimetype=self._props.get('MimeType','') + ) + item.update({k:v for k,v in self.info.items() if k in properties}) + return item + class Window(xbmcgui.Window): def __init__(self, windowId=0): self._props = {} @@ -917,7 +961,6 @@ def select( action = pick_item(get_input(), ["Back", "Cancel"] + list, -2) if action < 0: return -1 - print(list[action]) return action def textviewer(self, heading, text, usemono=False): diff --git a/plugin.program.autowidget/resources/lib/menu.py b/plugin.program.autowidget/resources/lib/menu.py index 885528fa..b13d4377 100644 --- a/plugin.program.autowidget/resources/lib/menu.py +++ b/plugin.program.autowidget/resources/lib/menu.py @@ -314,7 +314,7 @@ def show_path(group_id, path_label, widget_id, path, idx=0, titles=None, num=1, directory.add_menu_item(title=title[0], path=file['file'], - art=file['art'], + art=file.get('art',{}), info=file, isFolder=file['filetype'] == 'directory', props=properties) From 6d7456a32d8bdc320950f59262ea2c343788ebb9 Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Fri, 5 Mar 2021 12:29:15 +0700 Subject: [PATCH 11/23] fix running test --- plugin.program.autowidget/cli.py | 5 +++-- plugin.program.autowidget/mock_kodi/__init__.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/plugin.program.autowidget/cli.py b/plugin.program.autowidget/cli.py index 164ed149..ffe60a81 100644 --- a/plugin.program.autowidget/cli.py +++ b/plugin.program.autowidget/cli.py @@ -28,6 +28,8 @@ def teardown(): def setup(): import tempfile MOCK.PROFILE_ROOT = tempfile.mkdtemp() + os.environ['SEREN_INTERACTIVE_MODE'] = 'True' + MOCK.INTERACTIVE_MODE = True _addon = xbmcaddon.Addon() @@ -92,7 +94,7 @@ def press(keys): def start_kodi(service=True): threading.Thread(target=MOCK.DIRECTORY.handle_directory, daemon = True).start() - time.sleep(0.1) # give the home menu enough time to output + time.sleep(1) # give the home menu enough time to output if service: service = threading.Thread(target=start_service, daemon = True).start() time.sleep(1) # give the home menu enough time to output @@ -206,7 +208,6 @@ def test_add_widget_group(): """ if __name__ == '__main__': - os.environ['SEREN_INTERACTIVE_MODE'] = 'True' setup() doctest.testmod() teardown() diff --git a/plugin.program.autowidget/mock_kodi/__init__.py b/plugin.program.autowidget/mock_kodi/__init__.py index 54f464d4..b1f17564 100644 --- a/plugin.program.autowidget/mock_kodi/__init__.py +++ b/plugin.program.autowidget/mock_kodi/__init__.py @@ -131,6 +131,7 @@ def handle_directory(self): :return: :rtype: """ + import pdb; pdb.set_trace() if not MOCK.INTERACTIVE_MODE: return From 89fe988131ef7181f86d6bd9edc5a69e4623f7dc Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Fri, 5 Mar 2021 13:43:42 +0700 Subject: [PATCH 12/23] remove pdb --- plugin.program.autowidget/cli.py | 1 - plugin.program.autowidget/mock_kodi/__init__.py | 1 - 2 files changed, 2 deletions(-) diff --git a/plugin.program.autowidget/cli.py b/plugin.program.autowidget/cli.py index ffe60a81..edc2c944 100644 --- a/plugin.program.autowidget/cli.py +++ b/plugin.program.autowidget/cli.py @@ -170,7 +170,6 @@ def test_add_widget_group(): Enter Action Number >>> press("Widget1") - LOGINFO - plugin.program.autowidget: [ mode: group ][ group: widget1-... ] ------------------------------- -1) Back 0) Home diff --git a/plugin.program.autowidget/mock_kodi/__init__.py b/plugin.program.autowidget/mock_kodi/__init__.py index b1f17564..54f464d4 100644 --- a/plugin.program.autowidget/mock_kodi/__init__.py +++ b/plugin.program.autowidget/mock_kodi/__init__.py @@ -131,7 +131,6 @@ def handle_directory(self): :return: :rtype: """ - import pdb; pdb.set_trace() if not MOCK.INTERACTIVE_MODE: return From 4893bf34230aa87fb16b9f3f1cee2dc4d2e5981e Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Fri, 5 Mar 2021 22:40:12 +0700 Subject: [PATCH 13/23] fix elipsis in test --- plugin.program.autowidget/cli.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugin.program.autowidget/cli.py b/plugin.program.autowidget/cli.py index edc2c944..eda95a68 100644 --- a/plugin.program.autowidget/cli.py +++ b/plugin.program.autowidget/cli.py @@ -170,6 +170,7 @@ def test_add_widget_group(): Enter Action Number >>> press("Widget1") + LOGINFO - plugin.program.autowidget: [ mode: group ][ group: widget1-... ] ------------------------------- -1) Back 0) Home @@ -182,10 +183,10 @@ def test_add_widget_group(): Enter Action Number >>> press("Widget1 (Cycling)") - LOGINFO - plugin.program.autowidget: [ mode: path ][ action: cycling ][ group: widget1-... + LOGINFO - plugin.program.autowidget: [ mode: path ][ action: cycling ][ group: widget1-... ] Choose an Action 0) Random Path - 1) Next Path + 1) Next Path >>> press("Next Path") LOGINFO - plugin.program.autowidget: Empty cache 0B (exp:-1 day, 23:..., last:0:00:00): ... ['...'] @@ -208,5 +209,5 @@ def test_add_widget_group(): if __name__ == '__main__': setup() - doctest.testmod() + doctest.testmod(optionflags=doctest.ELLIPSIS|doctest.REPORT_NDIFF|doctest.REPORT_ONLY_FIRST_FAILURE) teardown() From c527b4543861d2204d9810ad63f02067b897f58a Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Fri, 5 Mar 2021 22:49:43 +0700 Subject: [PATCH 14/23] gitignore venv stuff --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index f3dfdbf8..fb52fa53 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ Thumbs.db *~ .cache +lib +bin +.venv +.vscode From 39d28fa9deee3aedd435a5639576f1392762d6e5 Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Fri, 5 Mar 2021 23:10:01 +0700 Subject: [PATCH 15/23] passed first test --- .gitignore | 1 + plugin.program.autowidget/cli.py | 8 ++++---- plugin.program.autowidget/mock_kodi/__init__.py | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index fb52fa53..c0dac928 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ lib bin .venv .vscode +/venv diff --git a/plugin.program.autowidget/cli.py b/plugin.program.autowidget/cli.py index eda95a68..bc4e0944 100644 --- a/plugin.program.autowidget/cli.py +++ b/plugin.program.autowidget/cli.py @@ -77,7 +77,7 @@ def home(path): MOCK.DIRECTORY.register_action("", home) def dummy_folder(path): - for i in range(1,20): + for i in range(1,21): p = "plugin://dummy/item{}".format(i) xbmcplugin.addDirectoryItem( handle=1, @@ -189,9 +189,9 @@ def test_add_widget_group(): 1) Next Path >>> press("Next Path") - LOGINFO - plugin.program.autowidget: Empty cache 0B (exp:-1 day, 23:..., last:0:00:00): ... ['...'] + LOGINFO - plugin.program.autowidget: Empty cache 0B (exp:-1 day, ... LOGINFO - plugin.program.autowidget: Blocking cache path read: ... - LOGINFO - plugin.program.autowidget: Wrote cache ... (exp:0:02:.., last:0:00:00): ... ['...'] + LOGINFO - plugin.program.autowidget: Wrote cache ... ------------------------------- -1) Back 0) Home @@ -200,7 +200,7 @@ def test_add_widget_group(): 2) Dummy Item 2 3) Dummy Item 3 4) Dummy Item 4 - ... + ... 20) Dummy Item 20 ------------------------------- Enter Action Number diff --git a/plugin.program.autowidget/mock_kodi/__init__.py b/plugin.program.autowidget/mock_kodi/__init__.py index 54f464d4..0be90a02 100644 --- a/plugin.program.autowidget/mock_kodi/__init__.py +++ b/plugin.program.autowidget/mock_kodi/__init__.py @@ -250,6 +250,7 @@ def get_items_dictionary(self, path=None, properties=[]): """ if path is not None: old_items = self.items + self.items = [] self._execute_action(path) result = json.loads(json.dumps([i.toJSONRPC(properties) for _,i,_ in self.items], cls=JsonEncoder)) if path is not None: From 93b4842c172447e11c25755512ae8d42db508889 Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Mon, 8 Mar 2021 16:21:26 +0700 Subject: [PATCH 16/23] fix test isolation and put tests and mock at top level as they shouldn't be in teh plugin --- requirements.txt | 1 + .../mock_kodi/__init__.py | 24 +++- .../cli.py => tests/tests.py | 125 +++++++++++------- 3 files changed, 93 insertions(+), 57 deletions(-) rename {plugin.program.autowidget => tests}/mock_kodi/__init__.py (97%) rename plugin.program.autowidget/cli.py => tests/tests.py (65%) diff --git a/requirements.txt b/requirements.txt index b50d6b6f..3207e266 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,3 +22,4 @@ requests-mock Mock tzlocal Pillow +pytest diff --git a/plugin.program.autowidget/mock_kodi/__init__.py b/tests/mock_kodi/__init__.py similarity index 97% rename from plugin.program.autowidget/mock_kodi/__init__.py rename to tests/mock_kodi/__init__.py index 0be90a02..838a0dd2 100644 --- a/plugin.program.autowidget/mock_kodi/__init__.py +++ b/tests/mock_kodi/__init__.py @@ -236,13 +236,25 @@ def _execute_action(self, next_path=None): if not next_path.startswith(path): continue if type(script) == type(""): - p = urlparse(next_path) - sys.argv = ["{}://{}".format(p.scheme, p.hostname), 1, "?"+p.query] - runpy.run_module(script, run_name='__main__',) + self._run_addon_script(script, next_path) else: script(next_path) break + def _run_addon_script(self, script, path): + argv = sys.argv + p = urlparse(path) + sys.argv = ["{}://{}".format(p.scheme, p.hostname), 1, "?"+p.query] + + sys.path.insert(0, MOCK.SEREN_ROOT) # This allows relative imports to work. There might be a better solution + #script = os.path.join(MOCK.SEREN_ROOT, script) + # TODO: should really use run_path and include full path seems to get different behaviour for some reason + # Context menus worked but main plugin didn't display the menu for some reason + runpy.run_module(script, run_name='__main__',) + sys.path.pop(0) + sys.argv = argv + + def get_items_dictionary(self, path=None, properties=[]): """ :return: @@ -307,11 +319,11 @@ def register_action(self, path, script): # TODO: read config from the addon.xml for actions and context menu self.action_callbacks[path] = script - def register_contextmenu(self, label, plugin, module, visible=None): + def register_contextmenu(self, label, plugin, script, visible=None): # TODO: read config from the addon.xml for actions and context menu - path = "plugin://{}/{}".format(plugin, module) # HACK: there is probably an offical way to encode + path = "plugin://{}/{}".format(plugin, label.replace(" ", "_")) # HACK: there is probably an offical way to encode self.context_callbacks.append((label, path, visible)) - self.action_callbacks[path] = module + self.action_callbacks[path] = script class SerenStubs: diff --git a/plugin.program.autowidget/cli.py b/tests/tests.py similarity index 65% rename from plugin.program.autowidget/cli.py rename to tests/tests.py index bc4e0944..3b83206f 100644 --- a/plugin.program.autowidget/cli.py +++ b/tests/tests.py @@ -1,4 +1,4 @@ -from mock_kodi import MOCK +import mock_kodi import os import threading import xbmcgui @@ -11,33 +11,31 @@ import sys import doctest import time +import pytest +import tempfile +import shutil -def execute_callback(): - runpy.run_module('foobar', run_name='__main__') - -def start_service(): - from resources.lib import refresh +@pytest.fixture +def service(): + from ..plugin.program.autowidget.resources.lib import refresh # need to ensure loaded late due to caching addon path _monitor = refresh.RefreshService() _monitor.waitForAbort() -def teardown(): - import shutil - shutil.rmtree(MOCK.PROFILE_ROOT) - -def setup(): - import tempfile - MOCK.PROFILE_ROOT = tempfile.mkdtemp() - os.environ['SEREN_INTERACTIVE_MODE'] = 'True' - MOCK.INTERACTIVE_MODE = True - +@pytest.fixture +def autowidget(): + mock_kodi.MOCK = mock_kodi.MockKodi() + mock_kodi.MOCK.SEREN_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__),"../plugin.program.autowidget")) + mock_kodi.MOCK.PROFILE_ROOT = tempfile.mkdtemp() + #os.environ['SEREN_INTERACTIVE_MODE'] = 'True' + mock_kodi.MOCK.INTERACTIVE_MODE = True _addon = xbmcaddon.Addon() # # # String.IsEqual(Window(10000).Property(context.autowidget),true) # - MOCK.DIRECTORY.register_contextmenu( + mock_kodi.MOCK.DIRECTORY.register_contextmenu( _addon.getLocalizedString(32003), "plugin.program.autowidget", "context_add", @@ -48,61 +46,70 @@ def setup(): # # String.Contains(ListItem.FolderPath, plugin://plugin.program.autowidget) # - MOCK.DIRECTORY.register_contextmenu( + mock_kodi.MOCK.DIRECTORY.register_contextmenu( _addon.getLocalizedString(32006), "plugin.program.autowidget", "context_refresh", lambda : True ) + path = "plugin://plugin.program.autowidget" + mock_kodi.MOCK.DIRECTORY.register_action(path, "main") + yield path + # teardown after test + shutil.rmtree(mock_kodi.MOCK.PROFILE_ROOT) + +@pytest.fixture +def dummy(): + def dummy_folder(path): + for i in range(1,21): + p = "plugin://dummy/item{}".format(i) + xbmcplugin.addDirectoryItem( + handle=1, + url=p, + listitem=xbmcgui.ListItem("Dummy Item {}".format(i), path=p), + isFolder=False + ) + xbmcplugin.endOfDirectory(handle=1) + path = "plugin://dummy" + mock_kodi.MOCK.DIRECTORY.register_action(path, dummy_folder) + return path - MOCK.DIRECTORY.register_action("plugin://plugin.program.autowidget", "main") - +@pytest.fixture +def home_with_dummy(autowidget, dummy): def home(path): url="plugin://plugin.program.autowidget/" xbmcplugin.addDirectoryItem( handle=1, - url=url, - listitem=xbmcgui.ListItem("AutoWidget"), + url=autowidget, + listitem=xbmcgui.ListItem("AutoWidget", path=autowidget), isFolder=True ) - # add our fake plugin - url="plugin://dummy/" + # add our fake plugin xbmcplugin.addDirectoryItem( handle=1, - url=url, - listitem=xbmcgui.ListItem("Dummy",path=url), + url=dummy, + listitem=xbmcgui.ListItem("Dummy",path=dummy), isFolder=True ) xbmcplugin.endOfDirectory(handle=1) - MOCK.DIRECTORY.register_action("", home) - - def dummy_folder(path): - for i in range(1,21): - p = "plugin://dummy/item{}".format(i) - xbmcplugin.addDirectoryItem( - handle=1, - url=p, - listitem=xbmcgui.ListItem("Dummy Item {}".format(i), path=p), - isFolder=False - ) - xbmcplugin.endOfDirectory(handle=1) - MOCK.DIRECTORY.register_action("plugin://dummy", dummy_folder) + mock_kodi.MOCK.DIRECTORY.register_action("", home) def press(keys): - MOCK.INPUT_QUEUE.put(keys) - MOCK.INPUT_QUEUE.join() # wait until the action got processed (ie until we wait for more input) + mock_kodi.MOCK.INPUT_QUEUE.put(keys) + mock_kodi.MOCK.INPUT_QUEUE.join() # wait until the action got processed (ie until we wait for more input) -def start_kodi(service=True): - threading.Thread(target=MOCK.DIRECTORY.handle_directory, daemon = True).start() +def start_kodi(use_service=True): + threading.Thread(target=mock_kodi.MOCK.DIRECTORY.handle_directory, daemon = True).start() time.sleep(1) # give the home menu enough time to output - if service: - service = threading.Thread(target=start_service, daemon = True).start() + if use_service: + t = threading.Thread(target=service, daemon = True).start() time.sleep(1) # give the home menu enough time to output -def test_add_widget_group(): +def test_add_widget_cycling(): """ - >>> start_kodi(service=False) + >>> getfixture("home_with_dummy") # TODO: + >>> start_kodi(False) ------------------------------- -1) Back 0) Home @@ -207,7 +214,23 @@ def test_add_widget_group(): """ -if __name__ == '__main__': - setup() - doctest.testmod(optionflags=doctest.ELLIPSIS|doctest.REPORT_NDIFF|doctest.REPORT_ONLY_FIRST_FAILURE) - teardown() +def test_add_widget_merged(): + """ + >>> getfixture("home_with_dummy") # TODO: + >>> start_kodi(False) + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) AutoWidget + 2) Dummy + ------------------------------- + Enter Action Number + """ + +# if __name__ == '__main__': +# autowidget() +# dummy() +# home_with_dummy() +# doctest.testmod(optionflags=doctest.ELLIPSIS|doctest.REPORT_NDIFF|doctest.REPORT_ONLY_FIRST_FAILURE) +# #teardown() From 700ffdedc578ccc97ce8dd6d5f9c4ecac219b53e Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Tue, 9 Mar 2021 12:01:49 +0700 Subject: [PATCH 17/23] add missing pytest.ini --- pytest.ini | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 pytest.ini diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..83c41689 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +addopts = --doctest-modules +doctest_optionflags = NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL ELLIPSIS +python_files = test_*.py tests.py +testpaths = tests \ No newline at end of file From 9d361f19015f52fd68c840b9ce6e83bb0b65873b Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Fri, 12 Mar 2021 17:23:20 +0700 Subject: [PATCH 18/23] add in github action to test --- .github/workflows/main.yml | 110 +++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..4384f57c --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,110 @@ +name: CI + +on: + push: + branches-ignore: + - "master" + - "releases/**" + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.9'] + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: install + run: | + pip install -r requirements.txt + + - name: test + run: | + pytest + - name: createcoverage + run: | + pytest --cov=resources/lib + coveralls + - name: make package + run: | + sed -i "s/name=\"AutoWidget\" version=\"CURRENT_VERSION\"/name=\"AutoWidget\" version=\"${CIRCLE_TAG-0.0.$CIRCLE_BUILD_NUM}\"/" ./plugin.program.autowidget/addon.xml + zip -Xr /tmp/plugin.program.autowidget.zip plugin.program.autowidget + # - store_artifacts: + # path: /tmp/plugin.program.autowidget.zip + # - persist_to_workspace: + # root: /tmp + # paths: + # - plugin.program.autowidget.zip + + +# circle ci config + +# jobs: +# build: +# docker: +# - image: circleci/python:2 +# steps: +# - checkout +# release: +# parameters: +# commit_branch: +# type: string +# default: repo +# overwrite: +# type: boolean +# default: false +# docker: +# - image: circleci/python:2 +# steps: +# - attach_workspace: +# at: /tmp +# - checkout +# - run: | +# git checkout << parameters.commit_branch >> +# python create_repository.py -d ./zips /tmp/plugin.program.autowidget.zip +# find ./zips -name "*.zip" | xargs python create_repository.py -d ./zips +# - unless: +# condition: << parameters.overwrite >> +# steps: +# run: git ls-files -m ./zips/|grep -v ".zip" +# - run: | +# git add ./zips +# git commit -m "Release version ${CIRCLE_TAG-0.0.$CIRCLE_BUILD_NUM}" +# git push +# workflows: +# version: 2 +# build_and_release: +# jobs: +# - build: +# filters: +# tags: +# only: /.*/ +# branches: +# ignore: +# - repo +# - devrepo +# - release: +# context: publish +# requires: +# - build +# filters: +# branches: +# ignore: /.*/ +# tags: +# only: /.*/ +# - release: +# commit_branch: devrepo +# overwrite: true +# context: publish +# requires: +# - build +# filters: +# branches: +# ignore: +# - devrepo +# - master +# - repo From e32708b3425ee2499436dc94f334090f1a06cc47 Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Fri, 12 Mar 2021 17:31:28 +0700 Subject: [PATCH 19/23] fix coverate dep --- .github/workflows/main.yml | 2 +- requirements.txt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4384f57c..d87b2288 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,7 +27,7 @@ jobs: pytest - name: createcoverage run: | - pytest --cov=resources/lib + pytest --cov=plugin.program.autowidget coveralls - name: make package run: | diff --git a/requirements.txt b/requirements.txt index 3207e266..d1da4f27 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,3 +23,5 @@ Mock tzlocal Pillow pytest +pytest-cov +coveralls From 23f42d6167304e29823ae4eb236b7a1f9dc44447 Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Fri, 12 Mar 2021 17:36:23 +0700 Subject: [PATCH 20/23] add coveralls --- .github/workflows/main.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d87b2288..e1230ffc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,7 +28,10 @@ jobs: - name: createcoverage run: | pytest --cov=plugin.program.autowidget - coveralls + - name: Coveralls + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} - name: make package run: | sed -i "s/name=\"AutoWidget\" version=\"CURRENT_VERSION\"/name=\"AutoWidget\" version=\"${CIRCLE_TAG-0.0.$CIRCLE_BUILD_NUM}\"/" ./plugin.program.autowidget/addon.xml From 79ade754369c8856b9fcc61ad0561be839308c93 Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Fri, 12 Mar 2021 17:39:09 +0700 Subject: [PATCH 21/23] remove coveralls for now --- .github/workflows/main.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e1230ffc..03c814ec 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,10 +28,10 @@ jobs: - name: createcoverage run: | pytest --cov=plugin.program.autowidget - - name: Coveralls - uses: coverallsapp/github-action@master - with: - github-token: ${{ secrets.GITHUB_TOKEN }} + # - name: Coveralls + # uses: coverallsapp/github-action@master + # with: + # github-token: ${{ secrets.GITHUB_TOKEN }} - name: make package run: | sed -i "s/name=\"AutoWidget\" version=\"CURRENT_VERSION\"/name=\"AutoWidget\" version=\"${CIRCLE_TAG-0.0.$CIRCLE_BUILD_NUM}\"/" ./plugin.program.autowidget/addon.xml From ac75131d9ecac6c6f74bc159445d325011f69fef Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Mon, 19 Jul 2021 23:06:26 +0700 Subject: [PATCH 22/23] split fixtures and tests --- tests/conftest.py | 134 ++++++++++++++++++++++++++++ tests/mock_kodi/__init__.py | 9 +- tests/test_caching.py | 171 ++++++++++++++++++++++++++++++++++++ 3 files changed, 309 insertions(+), 5 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_caching.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..97efd6b4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,134 @@ +import mock_kodi +import os +import threading +import xbmcgui +import xbmcplugin +import xbmcaddon +import xbmc +from mock_kodi import makedirs +import runpy +from urllib.parse import urlparse +import sys +import doctest +import time +import pytest +import tempfile +import shutil + + +@pytest.fixture +def service(): + # from ..plugin.program.autowidget.resources.lib import refresh # need to ensure loaded late due to caching addon path + # _monitor = refresh.RefreshService() + # _monitor.waitForAbort() + pass + +@pytest.fixture +def autowidget(): + mock_kodi.MOCK = mock_kodi.MockKodi() + mock_kodi.MOCK.SEREN_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__),"../plugin.program.autowidget")) + mock_kodi.MOCK.PROFILE_ROOT = tempfile.mkdtemp() + # os.environ['SEREN_INTERACTIVE_MODE'] = 'True' + mock_kodi.MOCK.INTERACTIVE_MODE = True + _addon = xbmcaddon.Addon() + + # + # + # String.IsEqual(Window(10000).Property(context.autowidget),true) + # + mock_kodi.MOCK.DIRECTORY.register_contextmenu( + _addon.getLocalizedString(32003), + "plugin.program.autowidget", + "context_add", + lambda : True + ) + + # + # + # String.Contains(ListItem.FolderPath, plugin://plugin.program.autowidget) + # + mock_kodi.MOCK.DIRECTORY.register_contextmenu( + _addon.getLocalizedString(32006), + "plugin.program.autowidget", + "context_refresh", + lambda : True + ) + path = "plugin://plugin.program.autowidget" + mock_kodi.MOCK.DIRECTORY.register_action(path, "main") + yield path + # teardown after test + shutil.rmtree(mock_kodi.MOCK.PROFILE_ROOT) + +@pytest.fixture +def dummy(): + def dummy_folder(path): + for i in range(1,21): + p = "plugin://dummy/item{}".format(i) + xbmcplugin.addDirectoryItem( + handle=1, + url=p, + listitem=xbmcgui.ListItem("Dummy Item {}".format(i), path=p), + isFolder=False + ) + xbmcplugin.endOfDirectory(handle=1) + path = "plugin://dummy" + mock_kodi.MOCK.DIRECTORY.register_action(path, dummy_folder) + return path + + +@pytest.fixture +def dummy2(): + "Register changed contents under same path" + def dummy_folder(path): + for i in range(1, 21): + p = "plugin://dummy/item{}".format(i) + xbmcplugin.addDirectoryItem( + handle=1, + url=p, + listitem=xbmcgui.ListItem("Dummy2 Item {}".format(i), path=p), + isFolder=False + ) + xbmcplugin.endOfDirectory(handle=1) + path = "plugin://dummy" + mock_kodi.MOCK.DIRECTORY.register_action(path, dummy_folder) + return path + + +@pytest.fixture +def home_with_dummy(autowidget, dummy): + def home(path): + url="plugin://plugin.program.autowidget/" + xbmcplugin.addDirectoryItem( + handle=1, + url=autowidget, + listitem=xbmcgui.ListItem("AutoWidget", path=autowidget), + isFolder=True + ) + # add our fake plugin + xbmcplugin.addDirectoryItem( + handle=1, + url=dummy, + listitem=xbmcgui.ListItem("Dummy", path=dummy), + isFolder=True + ) + xbmcplugin.endOfDirectory(handle=1) + mock_kodi.MOCK.DIRECTORY.register_action("", home) + + +def press(keys): + for input in keys.split(" > "): + mock_kodi.MOCK.INPUT_QUEUE.put(input) + for _ in keys.split(" > "): + mock_kodi.MOCK.INPUT_QUEUE.join() # wait until the action got processed (ie until we wait for more input) + + +@pytest.fixture +def start_kodi(): + threading.Thread(target=mock_kodi.MOCK.DIRECTORY.handle_directory, daemon=True).start() + time.sleep(1) # give the home menu enough time to output + +@pytest.fixture +def start_kodi_with_service(start_kodi, service): + start_kodi() + t = threading.Thread(target=service, daemon=True).start() + time.sleep(1) # give the home menu enough time to output diff --git a/tests/mock_kodi/__init__.py b/tests/mock_kodi/__init__.py index 838a0dd2..cdacec6f 100644 --- a/tests/mock_kodi/__init__.py +++ b/tests/mock_kodi/__init__.py @@ -169,7 +169,7 @@ def handle_directory(self): print("Please enter a valid entry") def _try_handle_menu_action(self, selected): - action = pick_item(selected, ["Back", "Home"]+[str(i[1]) for i in self.items], -2) + action = pick_item(selected, ["Back", "Home"] + [str(i[1]) for i in self.items], -2) if action is None: return False if action == -2: @@ -232,7 +232,7 @@ def _execute_action(self, next_path=None): #from resources.lib.modules.globals import g #g.init_globals(["", 0, self.next_action]) - for path,script in sorted(self.action_callbacks.items(), reverse=True): + for path, script in sorted(self.action_callbacks.items(), reverse=True): if not next_path.startswith(path): continue if type(script) == type(""): @@ -264,7 +264,7 @@ def get_items_dictionary(self, path=None, properties=[]): old_items = self.items self.items = [] self._execute_action(path) - result = json.loads(json.dumps([i.toJSONRPC(properties) for _,i,_ in self.items], cls=JsonEncoder)) + result = json.loads(json.dumps([i.toJSONRPC(properties) for _, i, _ in self.items], cls=JsonEncoder)) if path is not None: self.items = old_items else: @@ -285,7 +285,7 @@ def getInfoLabel(key, *params): @staticmethod def IsActive(window): - if window=='home': + if window == 'home': return self.next_action == "" raise Exception(f"Not handled Window.IsActive({window})") @@ -314,7 +314,6 @@ def getInfoLabel(self, key): else: raise Exception(f"Not found {key}") - def register_action(self, path, script): # TODO: read config from the addon.xml for actions and context menu self.action_callbacks[path] = script diff --git a/tests/test_caching.py b/tests/test_caching.py new file mode 100644 index 00000000..4b96f232 --- /dev/null +++ b/tests/test_caching.py @@ -0,0 +1,171 @@ +from conftest import press + +def test_add_widget_cycling(): + """ + >>> getfixture("home_with_dummy") # TODO: + >>> getfixture("start_kodi") + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) AutoWidget + 2) Dummy + ------------------------------- + Enter Action Number + + >>> press("c2") + 1) Add to AutoWidget Group + + >>> press("Add to AutoWidget Group") + Add as + 0) Shortcut + 1) Widget + 2) Clone as Shortcut Group + 3) Explode as Widget Group + + >>> press("Widget") + Choose a Group + 0) Create New Widget Group + + >>> press("Create New Widget Group") + Name for Group + + >>> press("Widget1") + Choose a Group + 0) Create New Widget Group + 1) Widget1 + + >>> press("Widget1") + Widget Label + + >>> press("My Label") + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) AutoWidget + 2) Dummy + ------------------------------- + Enter Action Number + + >>> press("AutoWidget") + LOGINFO - plugin.program.autowidget: [ root ] + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) My Groups + 2) Active Widgets + 3) Tools + ------------------------------- + Enter Action Number + + >>> press("My Groups") + LOGINFO - plugin.program.autowidget: [ mode: group ] + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) Widget1 + ------------------------------- + Enter Action Number + + >>> press("Widget1") + LOGINFO - plugin.program.autowidget: [ mode: group ][ group: widget1-... ] + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) My Label + 2) Widget1 (Static) + 3) Widget1 (Cycling) + 4) Widget1 (Merged) + ------------------------------- + Enter Action Number + + >>> press("Widget1 (Cycling)") + LOGINFO - plugin.program.autowidget: [ mode: path ][ action: cycling ][ group: widget1-... ] + Choose an Action + 0) Random Path + 1) Next Path + + >>> press("Next Path") + LOGINFO - plugin.program.autowidget: Empty cache 0B (exp:-1 day, ... + LOGINFO - plugin.program.autowidget: Blocking cache path read: ... + LOGINFO - plugin.program.autowidget: Wrote cache ... + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) Dummy Item 1 + 2) Dummy Item 2 + 3) Dummy Item 3 + 4) Dummy Item 4 + ... + 20) Dummy Item 20 + ------------------------------- + Enter Action Number + + """ + + +def test_add_widget_merged(): + """ + >>> getfixture("home_with_dummy") # TODO: + >>> getfixture("start_kodi") + ------------------------------- + -1) Back + 0) Home + ------------------------------- + 1) AutoWidget + 2) Dummy + ------------------------------- + Enter Action Number + """ + + +def test_cache_widget(): + """ + Add in a widget group + >>> getfixture("home_with_dummy") + >>> getfixture("start_kodi") + ---... + ... + >>> press("c2 > Add to AutoWidget Group > Widget > Create New Widget Group > Widget1" + ... " > Widget1 > My Label") + 1)... + ... + + Access it the first time it will get cached + >>> press("Home > AutoWidget > My Groups > Widget1 > Widget1 (Cycling) > Next Path") + ---... + ... + 1) Dummy Item 1 + ... + + Now change the connent of dummy menu + + >>> _ = getfixture("dummy2") + >>> press("Home > Dummy") + ---... + ... + 1) Dummy2 Item 1 + ... + + But our widget is still cached + + >>> press("Home > AutoWidget > My Groups > Widget1 > Widget1 (Cycling) > Next Path") + ---... + ... + 1) Dummy Item 1 + ... + + """ + + +# if __name__ == '__main__': +# autowidget() +# dummy() +# home_with_dummy() +# doctest.testmod(optionflags=doctest.ELLIPSIS|doctest.REPORT_NDIFF|doctest.REPORT_ONLY_FIRST_FAILURE) +# #teardown() From 8138d2f8cad7209e9e05bc9d642262ebf8af2983 Mon Sep 17 00:00:00 2001 From: Dylan Jay Date: Mon, 19 Jul 2021 23:06:35 +0700 Subject: [PATCH 23/23] remove tests --- tests/tests.py | 236 ------------------------------------------------- 1 file changed, 236 deletions(-) delete mode 100644 tests/tests.py diff --git a/tests/tests.py b/tests/tests.py deleted file mode 100644 index 3b83206f..00000000 --- a/tests/tests.py +++ /dev/null @@ -1,236 +0,0 @@ -import mock_kodi -import os -import threading -import xbmcgui -import xbmcplugin -import xbmcaddon -import xbmc -from mock_kodi import makedirs -import runpy -from urllib.parse import urlparse -import sys -import doctest -import time -import pytest -import tempfile -import shutil - - -@pytest.fixture -def service(): - from ..plugin.program.autowidget.resources.lib import refresh # need to ensure loaded late due to caching addon path - _monitor = refresh.RefreshService() - _monitor.waitForAbort() - -@pytest.fixture -def autowidget(): - mock_kodi.MOCK = mock_kodi.MockKodi() - mock_kodi.MOCK.SEREN_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__),"../plugin.program.autowidget")) - mock_kodi.MOCK.PROFILE_ROOT = tempfile.mkdtemp() - #os.environ['SEREN_INTERACTIVE_MODE'] = 'True' - mock_kodi.MOCK.INTERACTIVE_MODE = True - _addon = xbmcaddon.Addon() - - # - # - # String.IsEqual(Window(10000).Property(context.autowidget),true) - # - mock_kodi.MOCK.DIRECTORY.register_contextmenu( - _addon.getLocalizedString(32003), - "plugin.program.autowidget", - "context_add", - lambda : True - ) - - # - # - # String.Contains(ListItem.FolderPath, plugin://plugin.program.autowidget) - # - mock_kodi.MOCK.DIRECTORY.register_contextmenu( - _addon.getLocalizedString(32006), - "plugin.program.autowidget", - "context_refresh", - lambda : True - ) - path = "plugin://plugin.program.autowidget" - mock_kodi.MOCK.DIRECTORY.register_action(path, "main") - yield path - # teardown after test - shutil.rmtree(mock_kodi.MOCK.PROFILE_ROOT) - -@pytest.fixture -def dummy(): - def dummy_folder(path): - for i in range(1,21): - p = "plugin://dummy/item{}".format(i) - xbmcplugin.addDirectoryItem( - handle=1, - url=p, - listitem=xbmcgui.ListItem("Dummy Item {}".format(i), path=p), - isFolder=False - ) - xbmcplugin.endOfDirectory(handle=1) - path = "plugin://dummy" - mock_kodi.MOCK.DIRECTORY.register_action(path, dummy_folder) - return path - -@pytest.fixture -def home_with_dummy(autowidget, dummy): - def home(path): - url="plugin://plugin.program.autowidget/" - xbmcplugin.addDirectoryItem( - handle=1, - url=autowidget, - listitem=xbmcgui.ListItem("AutoWidget", path=autowidget), - isFolder=True - ) - # add our fake plugin - xbmcplugin.addDirectoryItem( - handle=1, - url=dummy, - listitem=xbmcgui.ListItem("Dummy",path=dummy), - isFolder=True - ) - xbmcplugin.endOfDirectory(handle=1) - mock_kodi.MOCK.DIRECTORY.register_action("", home) - -def press(keys): - mock_kodi.MOCK.INPUT_QUEUE.put(keys) - mock_kodi.MOCK.INPUT_QUEUE.join() # wait until the action got processed (ie until we wait for more input) - -def start_kodi(use_service=True): - threading.Thread(target=mock_kodi.MOCK.DIRECTORY.handle_directory, daemon = True).start() - time.sleep(1) # give the home menu enough time to output - if use_service: - t = threading.Thread(target=service, daemon = True).start() - time.sleep(1) # give the home menu enough time to output - - -def test_add_widget_cycling(): - """ - >>> getfixture("home_with_dummy") # TODO: - >>> start_kodi(False) - ------------------------------- - -1) Back - 0) Home - ------------------------------- - 1) AutoWidget - 2) Dummy - ------------------------------- - Enter Action Number - - >>> press("c2") - 1) Add to AutoWidget Group - - >>> press("Add to AutoWidget Group") - Add as - 0) Shortcut - 1) Widget - 2) Clone as Shortcut Group - 3) Explode as Widget Group - - >>> press("Widget") - Choose a Group - 0) Create New Widget Group - - >>> press("Create New Widget Group") - Name for Group - - >>> press("Widget1") - Choose a Group - 0) Create New Widget Group - 1) Widget1 - - >>> press("Widget1") - Widget Label - - >>> press("My Label") - ------------------------------- - -1) Back - 0) Home - ------------------------------- - 1) AutoWidget - 2) Dummy - ------------------------------- - Enter Action Number - - >>> press("AutoWidget") - LOGINFO - plugin.program.autowidget: [ root ] - ------------------------------- - -1) Back - 0) Home - ------------------------------- - 1) My Groups - 2) Active Widgets - 3) Tools - ------------------------------- - Enter Action Number - - >>> press("My Groups") - LOGINFO - plugin.program.autowidget: [ mode: group ] - ------------------------------- - -1) Back - 0) Home - ------------------------------- - 1) Widget1 - ------------------------------- - Enter Action Number - - >>> press("Widget1") - LOGINFO - plugin.program.autowidget: [ mode: group ][ group: widget1-... ] - ------------------------------- - -1) Back - 0) Home - ------------------------------- - 1) My Label - 2) Widget1 (Static) - 3) Widget1 (Cycling) - 4) Widget1 (Merged) - ------------------------------- - Enter Action Number - - >>> press("Widget1 (Cycling)") - LOGINFO - plugin.program.autowidget: [ mode: path ][ action: cycling ][ group: widget1-... ] - Choose an Action - 0) Random Path - 1) Next Path - - >>> press("Next Path") - LOGINFO - plugin.program.autowidget: Empty cache 0B (exp:-1 day, ... - LOGINFO - plugin.program.autowidget: Blocking cache path read: ... - LOGINFO - plugin.program.autowidget: Wrote cache ... - ------------------------------- - -1) Back - 0) Home - ------------------------------- - 1) Dummy Item 1 - 2) Dummy Item 2 - 3) Dummy Item 3 - 4) Dummy Item 4 - ... - 20) Dummy Item 20 - ------------------------------- - Enter Action Number - - """ - -def test_add_widget_merged(): - """ - >>> getfixture("home_with_dummy") # TODO: - >>> start_kodi(False) - ------------------------------- - -1) Back - 0) Home - ------------------------------- - 1) AutoWidget - 2) Dummy - ------------------------------- - Enter Action Number - """ - -# if __name__ == '__main__': -# autowidget() -# dummy() -# home_with_dummy() -# doctest.testmod(optionflags=doctest.ELLIPSIS|doctest.REPORT_NDIFF|doctest.REPORT_ONLY_FIRST_FAILURE) -# #teardown()