Mercurial > libervia-backend
diff sat_frontends/jp/base.py @ 2562:26edcf3a30eb
core, setup: huge cleaning:
- moved directories from src and frontends/src to sat and sat_frontends, which is the recommanded naming convention
- move twisted directory to root
- removed all hacks from setup.py, and added missing dependencies, it is now clean
- use https URL for website in setup.py
- removed "Environment :: X11 Applications :: GTK", as wix is deprecated and removed
- renamed sat.sh to sat and fixed its installation
- added python_requires to specify Python version needed
- replaced glib2reactor which use deprecated code by gtk3reactor
sat can now be installed directly from virtualenv without using --system-site-packages anymore \o/
author | Goffi <goffi@goffi.org> |
---|---|
date | Mon, 02 Apr 2018 19:44:50 +0200 |
parents | frontends/src/jp/base.py@340128e0b354 |
children | 4011e4ee3151 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_frontends/jp/base.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,1084 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# jp: a SAT command line tool +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from sat.core.i18n import _ + +### logging ### +import logging as log +log.basicConfig(level=log.DEBUG, + format='%(message)s') +### + +import sys +import locale +import os.path +import argparse +from glob import iglob +from importlib import import_module +from sat_frontends.tools.jid import JID +from sat.tools import config +from sat.tools.common import dynamic_import +from sat.tools.common import uri +from sat.core import exceptions +import sat_frontends.jp +from sat_frontends.jp.constants import Const as C +from sat_frontends.tools import misc +import xml.etree.ElementTree as ET # FIXME: used temporarily to manage XMLUI +import shlex +from collections import OrderedDict + +## bridge handling +# we get bridge name from conf and initialise the right class accordingly +main_config = config.parseMainConf() +bridge_name = config.getConfig(main_config, '', 'bridge', 'dbus') + + +# TODO: move loops handling in a separated module +if 'dbus' in bridge_name: + from gi.repository import GLib + + + class JPLoop(object): + + def __init__(self): + self.loop = GLib.MainLoop() + + def run(self): + self.loop.run() + + def quit(self): + self.loop.quit() + + def call_later(self, delay, callback, *args): + """call a callback repeatedly + + @param delay(int): delay between calls in ms + @param callback(callable): method to call + if the callback return True, the call will continue + else the calls will stop + @param *args: args of the callbac + """ + GLib.timeout_add(delay, callback, *args) + +else: + print u"can't start jp: only D-Bus bridge is currently handled" + sys.exit(C.EXIT_ERROR) + # FIXME: twisted loop can be used when jp can handle fully async bridges + # from twisted.internet import reactor + + # class JPLoop(object): + + # def run(self): + # reactor.run() + + # def quit(self): + # reactor.stop() + + # def _timeout_cb(self, args, callback, delay): + # ret = callback(*args) + # if ret: + # reactor.callLater(delay, self._timeout_cb, args, callback, delay) + + # def call_later(self, delay, callback, *args): + # delay = float(delay) / 1000 + # reactor.callLater(delay, self._timeout_cb, args, callback, delay) + +if bridge_name == "embedded": + from sat.core import sat_main + sat = sat_main.SAT() + +if sys.version_info < (2, 7, 3): + # XXX: shlex.split only handle unicode since python 2.7.3 + # this is a workaround for older versions + old_split = shlex.split + new_split = (lambda s, *a, **kw: [t.decode('utf-8') for t in old_split(s.encode('utf-8'), *a, **kw)] + if isinstance(s, unicode) else old_split(s, *a, **kw)) + shlex.split = new_split + +try: + import progressbar +except ImportError: + msg = (_(u'ProgressBar not available, please download it at http://pypi.python.org/pypi/progressbar\n') + + _(u'Progress bar deactivated\n--\n')) + print >>sys.stderr,msg.encode('utf-8') + progressbar=None + +#consts +PROG_NAME = u"jp" +DESCRIPTION = """This software is a command line tool for XMPP. +Get the latest version at """ + C.APP_URL + +COPYLEFT = u"""Copyright (C) 2009-2018 Jérôme Poisson, Adrien Cossa +This program comes with ABSOLUTELY NO WARRANTY; +This is free software, and you are welcome to redistribute it under certain conditions. +""" + +PROGRESS_DELAY = 10 # the progression will be checked every PROGRESS_DELAY ms + + +def unicode_decoder(arg): + # Needed to have unicode strings from arguments + return arg.decode(locale.getpreferredencoding()) + + +class Jp(object): + """ + This class can be use to establish a connection with the + bridge. Moreover, it should manage a main loop. + + To use it, you mainly have to redefine the method run to perform + specify what kind of operation you want to perform. + + """ + def __init__(self): + """ + + @attribute quit_on_progress_end (bool): set to False if you manage yourself exiting, + or if you want the user to stop by himself + @attribute progress_success(callable): method to call when progress just started + by default display a message + @attribute progress_success(callable): method to call when progress is successfully finished + by default display a message + @attribute progress_failure(callable): method to call when progress failed + by default display a message + """ + # FIXME: need_loop should be removed, everything must be async in bridge so + # loop will always be needed + bridge_module = dynamic_import.bridge(bridge_name, 'sat_frontends.bridge') + if bridge_module is None: + log.error(u"Can't import {} bridge".format(bridge_name)) + sys.exit(1) + + self.bridge = bridge_module.Bridge() + self.bridge.bridgeConnect(callback=self._bridgeCb, errback=self._bridgeEb) + + def _bridgeCb(self): + self.parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, + description=DESCRIPTION) + self._make_parents() + self.add_parser_options() + self.subparsers = self.parser.add_subparsers(title=_(u'Available commands'), dest='subparser_name') + self._auto_loop = False # when loop is used for internal reasons + self._need_loop = False + + # progress attributes + self._progress_id = None # TODO: manage several progress ids + self.quit_on_progress_end = True + + # outputs + self._outputs = {} + for type_ in C.OUTPUT_TYPES: + self._outputs[type_] = OrderedDict() + self.default_output = {} + + def _bridgeEb(self, failure): + if isinstance(failure, exceptions.BridgeExceptionNoService): + print(_(u"Can't connect to SàT backend, are you sure it's launched ?")) + elif isinstance(failure, exceptions.BridgeInitError): + print(_(u"Can't init bridge")) + else: + print(_(u"Error while initialising bridge: {}".format(failure))) + sys.exit(C.EXIT_BRIDGE_ERROR) + + @property + def version(self): + return self.bridge.getVersion() + + @property + def progress_id(self): + return self._progress_id + + @progress_id.setter + def progress_id(self, value): + self._progress_id = value + self.replayCache('progress_ids_cache') + + @property + def watch_progress(self): + try: + self.pbar + except AttributeError: + return False + else: + return True + + @watch_progress.setter + def watch_progress(self, watch_progress): + if watch_progress: + self.pbar = None + + @property + def verbosity(self): + try: + return self.args.verbose + except AttributeError: + return 0 + + def replayCache(self, cache_attribute): + """Replay cached signals + + @param cache_attribute(str): name of the attribute containing the cache + if the attribute doesn't exist, there is no cache and the call is ignored + else the cache must be a list of tuples containing the replay callback as first item, + then the arguments to use + """ + try: + cache = getattr(self, cache_attribute) + except AttributeError: + pass + else: + for cache_data in cache: + cache_data[0](*cache_data[1:]) + + def disp(self, msg, verbosity=0, error=False, no_lf=False): + """Print a message to user + + @param msg(unicode): message to print + @param verbosity(int): minimal verbosity to display the message + @param error(bool): if True, print to stderr instead of stdout + @param no_lf(bool): if True, do not emit line feed at the end of line + """ + if self.verbosity >= verbosity: + if error: + if no_lf: + print >>sys.stderr,msg.encode('utf-8'), + else: + print >>sys.stderr,msg.encode('utf-8') + else: + if no_lf: + print msg.encode('utf-8'), + else: + print msg.encode('utf-8') + + def output(self, type_, name, extra_outputs, data): + if name in extra_outputs: + extra_outputs[name](data) + else: + self._outputs[type_][name]['callback'](data) + + def addOnQuitCallback(self, callback, *args, **kwargs): + """Add a callback which will be called on quit command + + @param callback(callback): method to call + """ + try: + callbacks_list = self._onQuitCallbacks + except AttributeError: + callbacks_list = self._onQuitCallbacks = [] + finally: + callbacks_list.append((callback, args, kwargs)) + + def getOutputChoices(self, output_type): + """Return valid output filters for output_type + + @param output_type: True for default, + else can be any registered type + """ + return self._outputs[output_type].keys() + + def _make_parents(self): + self.parents = {} + + # we have a special case here as the start-session option is present only if connection is not needed, + # so we create two similar parents, one with the option, the other one without it + for parent_name in ('profile', 'profile_session'): + parent = self.parents[parent_name] = argparse.ArgumentParser(add_help=False) + parent.add_argument("-p", "--profile", action="store", type=str, default='@DEFAULT@', help=_("Use PROFILE profile key (default: %(default)s)")) + parent.add_argument("--pwd", action="store", type=unicode_decoder, default='', metavar='PASSWORD', help=_("Password used to connect profile, if necessary")) + + profile_parent, profile_session_parent = self.parents['profile'], self.parents['profile_session'] + + connect_short, connect_long, connect_action, connect_help = "-c", "--connect", "store_true", _(u"Connect the profile before doing anything else") + profile_parent.add_argument(connect_short, connect_long, action=connect_action, help=connect_help) + + profile_session_connect_group = profile_session_parent.add_mutually_exclusive_group() + profile_session_connect_group.add_argument(connect_short, connect_long, action=connect_action, help=connect_help) + profile_session_connect_group.add_argument("--start-session", action="store_true", help=_("Start a profile session without connecting")) + + progress_parent = self.parents['progress'] = argparse.ArgumentParser(add_help=False) + if progressbar: + progress_parent.add_argument("-P", "--progress", action="store_true", help=_("Show progress bar")) + + verbose_parent = self.parents['verbose'] = argparse.ArgumentParser(add_help=False) + verbose_parent.add_argument('--verbose', '-v', action='count', default=0, help=_(u"Add a verbosity level (can be used multiple times)")) + + draft_parent = self.parents['draft'] = argparse.ArgumentParser(add_help=False) + draft_group = draft_parent.add_argument_group(_('draft handling')) + draft_group.add_argument("-D", "--current", action="store_true", help=_(u"load current draft")) + draft_group.add_argument("-F", "--draft-path", type=unicode_decoder, help=_(u"path to a draft file to retrieve")) + + + def make_pubsub_group(self, flags, defaults): + """generate pubsub options according to flags + + @param flags(iterable[unicode]): see [CommandBase.__init__] + @param defaults(dict[unicode, unicode]): help text for default value + key can be "service" or "node" + value will be set in " (DEFAULT: {value})", or can be None to remove DEFAULT + @return (ArgumentParser): parser to add + """ + flags = misc.FlagsHandler(flags) + parent = argparse.ArgumentParser(add_help=False) + pubsub_group = parent.add_argument_group('pubsub') + pubsub_group.add_argument("-u", "--pubsub-url", type=unicode_decoder, + help=_(u"Pubsub URL (xmpp or http)")) + + service_help = _(u"JID of the PubSub service") + if not flags.service: + default = defaults.pop(u'service', _(u'PEP service')) + if default is not None: + service_help += _(u" (DEFAULT: {default})".format(default=default)) + pubsub_group.add_argument("-s", "--service", type=unicode_decoder, default=u'', + help=service_help) + + node_help = _(u"node to request") + if not flags.node: + default = defaults.pop(u'node', _(u'standard node')) + if default is not None: + node_help += _(u" (DEFAULT: {default})".format(default=default)) + pubsub_group.add_argument("-n", "--node", type=unicode_decoder, default=u'', help=node_help) + + if flags.single_item: + item_help = (u"item to retrieve") + if not flags.item: + default = defaults.pop(u'item', _(u'last item')) + if default is not None: + item_help += _(u" (DEFAULT: {default})".format(default=default)) + pubsub_group.add_argument("-i", "--item", type=unicode_decoder, help=item_help) + pubsub_group.add_argument("-L", "--last-item", action='store_true', help=_(u'retrieve last item')) + elif flags.multi_items: + # mutiple items + pubsub_group.add_argument("-i", "--item", type=unicode_decoder, action='append', dest='items', default=[], help=_(u"items to retrieve (DEFAULT: all)")) + if not flags.no_max: + pubsub_group.add_argument("-m", "--max", type=int, default=10, + help=_(u"maximum number of items to get ({no_limit} to get all items)".format(no_limit=C.NO_LIMIT))) + + if flags: + raise exceptions.InternalError('unknowns flags: {flags}'.format(flags=u', '.join(flags))) + if defaults: + raise exceptions.InternalError('unused defaults: {defaults}'.format(defaults=defaults)) + + return parent + + def add_parser_options(self): + self.parser.add_argument('--version', action='version', version=("%(name)s %(version)s %(copyleft)s" % {'name': PROG_NAME, 'version': self.version, 'copyleft': COPYLEFT})) + + def register_output(self, type_, name, callback, description="", default=False): + if type_ not in C.OUTPUT_TYPES: + log.error(u"Invalid output type {}".format(type_)) + return + self._outputs[type_][name] = {'callback': callback, + 'description': description + } + if default: + if type_ in self.default_output: + self.disp(_(u'there is already a default output for {}, ignoring new one').format(type_)) + else: + self.default_output[type_] = name + + + def parse_output_options(self): + options = self.command.args.output_opts + options_dict = {} + for option in options: + try: + key, value = option.split(u'=', 1) + except ValueError: + key, value = option, None + options_dict[key.strip()] = value.strip() if value is not None else None + return options_dict + + def check_output_options(self, accepted_set, options): + if not accepted_set.issuperset(options): + self.disp(u"The following output options are invalid: {invalid_options}".format( + invalid_options = u', '.join(set(options).difference(accepted_set))), + error=True) + self.quit(C.EXIT_BAD_ARG) + + def import_plugins(self): + """Automaticaly import commands and outputs in jp + + looks from modules names cmd_*.py in jp path and import them + """ + path = os.path.dirname(sat_frontends.jp.__file__) + # XXX: outputs must be imported before commands as they are used for arguments + for type_, pattern in ((C.PLUGIN_OUTPUT, 'output_*.py'), (C.PLUGIN_CMD, 'cmd_*.py')): + modules = (os.path.splitext(module)[0] for module in map(os.path.basename, iglob(os.path.join(path, pattern)))) + for module_name in modules: + module_path = "sat_frontends.jp." + module_name + try: + module = import_module(module_path) + self.import_plugin_module(module, type_) + except ImportError as e: + self.disp(_(u"Can't import {module_path} plugin, ignoring it: {msg}".format( + module_path = module_path, + msg = e)), error=True) + except exceptions.CancelError: + continue + except exceptions.MissingModule as e: + self.disp(_(u"Missing module for plugin {name}: {missing}".format( + name = module_path, + missing = e)), error=True) + + + def import_plugin_module(self, module, type_): + """add commands or outpus from a module to jp + + @param module: module containing commands or outputs + @param type_(str): one of C_PLUGIN_* + """ + try: + class_names = getattr(module, '__{}__'.format(type_)) + except AttributeError: + log.disp(_(u"Invalid plugin module [{type}] {module}").format(type=type_, module=module), error=True) + raise ImportError + else: + for class_name in class_names: + cls = getattr(module, class_name) + cls(self) + + def get_xmpp_uri_from_http(self, http_url): + """parse HTML page at http(s) URL, and looks for xmpp: uri""" + if http_url.startswith('https'): + scheme = u'https' + elif http_url.startswith('http'): + scheme = u'http' + else: + raise exceptions.InternalError(u'An HTTP scheme is expected in this method') + self.disp(u"{scheme} URL found, trying to find associated xmpp: URI".format(scheme=scheme.upper()),1) + # HTTP URL, we try to find xmpp: links + try: + from lxml import etree + except ImportError: + self.disp(u"lxml module must be installed to use http(s) scheme, please install it with \"pip install lxml\"", error=True) + self.host.quit(1) + import urllib2 + parser = etree.HTMLParser() + try: + root = etree.parse(urllib2.urlopen(http_url), parser) + except etree.XMLSyntaxError as e: + self.disp(_(u"Can't parse HTML page : {msg}").format(msg=e)) + links = [] + else: + links = root.xpath("//link[@rel='alternate' and starts-with(@href, 'xmpp:')]") + if not links: + self.disp(u'Could not find alternate "xmpp:" URI, can\'t find associated XMPP PubSub node/item', error=True) + self.host.quit(1) + xmpp_uri = links[0].get('href') + return xmpp_uri + + def parse_pubsub_args(self): + if self.args.pubsub_url is not None: + url = self.args.pubsub_url + + if url.startswith('http'): + # http(s) URL, we try to retrieve xmpp one from there + url = self.get_xmpp_uri_from_http(url) + + try: + uri_data = uri.parseXMPPUri(url) + except ValueError: + self.parser.error(_(u'invalid XMPP URL: {url}').format(url=url)) + else: + if uri_data[u'type'] == 'pubsub': + # URL is alright, we only set data not already set by other options + if not self.args.service: + self.args.service = uri_data[u'path'] + if not self.args.node: + self.args.node = uri_data[u'node'] + uri_item = uri_data.get(u'item') + if uri_item: + # there is an item in URI + # we use it only if item is not already set + # and item_last is not used either + try: + item = self.args.item + except AttributeError: + if not self.args.items: + self.args.items = [uri_item] + else: + if not item: + try: + item_last = self.args.item_last + except AttributeError: + item_last = False + if not item_last: + self.args.item = uri_item + else: + self.parser.error(_(u'XMPP URL is not a pubsub one: {url}').format(url=url)) + flags = self.args._cmd._pubsub_flags + # we check required arguments here instead of using add_arguments' required option + # because the required argument can be set in URL + if C.SERVICE in flags and not self.args.service: + self.parser.error(_(u"argument -s/--service is required")) + if C.NODE in flags and not self.args.node: + self.parser.error(_(u"argument -n/--node is required")) + if C.ITEM in flags and not self.args.item: + self.parser.error(_(u"argument -i/--item is required")) + + # FIXME: mutually groups can't be nested in a group and don't support title + # so we check conflict here. This may be fixed in Python 3, to be checked + try: + if self.args.item and self.args.item_last: + self.parser.error(_(u"--item and --item-last can't be used at the same time")) + except AttributeError: + pass + + def run(self, args=None, namespace=None): + self.args = self.parser.parse_args(args, namespace=None) + if self.args._cmd._use_pubsub: + self.parse_pubsub_args() + try: + self.args._cmd.run() + if self._need_loop or self._auto_loop: + self._start_loop() + except KeyboardInterrupt: + log.info(_("User interruption: good bye")) + + def _start_loop(self): + self.loop = JPLoop() + self.loop.run() + + def stop_loop(self): + try: + self.loop.quit() + except AttributeError: + pass + + def confirmOrQuit(self, message, cancel_message=_(u"action cancelled by user")): + """Request user to confirm action, and quit if he doesn't""" + + res = raw_input("{} (y/N)? ".format(message)) + if res not in ("y", "Y"): + self.disp(cancel_message) + self.quit(C.EXIT_USER_CANCELLED) + + def quitFromSignal(self, errcode=0): + """Same as self.quit, but from a signal handler + + /!\: return must be used after calling this method ! + """ + assert self._need_loop + # XXX: python-dbus will show a traceback if we exit in a signal handler + # so we use this little timeout trick to avoid it + self.loop.call_later(0, self.quit, errcode) + + def quit(self, errcode=0): + # first the onQuitCallbacks + try: + callbacks_list = self._onQuitCallbacks + except AttributeError: + pass + else: + for callback, args, kwargs in callbacks_list: + callback(*args, **kwargs) + + self.stop_loop() + sys.exit(errcode) + + def check_jids(self, jids): + """Check jids validity, transform roster name to corresponding jids + + @param profile: profile name + @param jids: list of jids + @return: List of jids + + """ + names2jid = {} + nodes2jid = {} + + for contact in self.bridge.getContacts(self.profile): + jid_s, attr, groups = contact + _jid = JID(jid_s) + try: + names2jid[attr["name"].lower()] = jid_s + except KeyError: + pass + + if _jid.node: + nodes2jid[_jid.node.lower()] = jid_s + + def expand_jid(jid): + _jid = jid.lower() + if _jid in names2jid: + expanded = names2jid[_jid] + elif _jid in nodes2jid: + expanded = nodes2jid[_jid] + else: + expanded = jid + return expanded.decode('utf-8') + + def check(jid): + if not jid.is_valid: + log.error (_("%s is not a valid JID !"), jid) + self.quit(1) + + dest_jids=[] + try: + for i in range(len(jids)): + dest_jids.append(expand_jid(jids[i])) + check(dest_jids[i]) + except AttributeError: + pass + + return dest_jids + + def connect_profile(self, callback): + """ Check if the profile is connected and do it if requested + + @param callback: method to call when profile is connected + @exit: - 1 when profile is not connected and --connect is not set + - 1 when the profile doesn't exists + - 1 when there is a connection error + """ + # FIXME: need better exit codes + + def cant_connect(failure): + log.error(_(u"Can't connect profile: {reason}").format(reason=failure)) + self.quit(1) + + def cant_start_session(failure): + log.error(_(u"Can't start {profile}'s session: {reason}").format(profile=self.profile, reason=failure)) + self.quit(1) + + self.profile = self.bridge.profileNameGet(self.args.profile) + + if not self.profile: + log.error(_("The profile [{profile}] doesn't exist").format(profile=self.args.profile)) + self.quit(1) + + try: + start_session = self.args.start_session + except AttributeError: + pass + else: + if start_session: + self.bridge.profileStartSession(self.args.pwd, self.profile, lambda dummy: callback(), cant_start_session) + self._auto_loop = True + return + elif not self.bridge.profileIsSessionStarted(self.profile): + if not self.args.connect: + log.error(_(u"Session for [{profile}] is not started, please start it before using jp, or use either --start-session or --connect option").format(profile=self.profile)) + self.quit(1) + else: + callback() + return + + + if not hasattr(self.args, 'connect'): + # a profile can be present without connect option (e.g. on profile creation/deletion) + return + elif self.args.connect is True: # if connection is asked, we connect the profile + self.bridge.connect(self.profile, self.args.pwd, {}, lambda dummy: callback(), cant_connect) + self._auto_loop = True + return + else: + if not self.bridge.isConnected(self.profile): + log.error(_(u"Profile [{profile}] is not connected, please connect it before using jp, or use --connect option").format(profile=self.profile)) + self.quit(1) + + callback() + + def get_full_jid(self, param_jid): + """Return the full jid if possible (add main resource when find a bare jid)""" + _jid = JID(param_jid) + if not _jid.resource: + #if the resource is not given, we try to add the main resource + main_resource = self.bridge.getMainResource(param_jid, self.profile) + if main_resource: + return "%s/%s" % (_jid.bare, main_resource) + return param_jid + + +class CommandBase(object): + + def __init__(self, host, name, use_profile=True, use_output=False, extra_outputs=None, + need_connect=None, help=None, **kwargs): + """Initialise CommandBase + + @param host: Jp instance + @param name(unicode): name of the new command + @param use_profile(bool): if True, add profile selection/connection commands + @param use_output(bool, unicode): if not False, add --output option + @param extra_outputs(dict): list of command specific outputs: + key is output name ("default" to use as main output) + value is a callable which will format the output (data will be used as only argument) + if a key already exists with normal outputs, the extra one will be used + @param need_connect(bool, None): True if profile connection is needed + False else (profile session must still be started) + None to set auto value (i.e. True if use_profile is set) + Can't be set if use_profile is False + @param help(unicode): help message to display + @param **kwargs: args passed to ArgumentParser + use_* are handled directly, they can be: + - use_progress(bool): if True, add progress bar activation option + progress* signals will be handled + - use_verbose(bool): if True, add verbosity option + - use_pubsub(bool): if True, add pubsub options + mandatory arguments are controlled by pubsub_req + - use_draft(bool): if True, add draft handling options + ** other arguments ** + - pubsub_flags(iterable[unicode]): tuple of flags to set pubsub options, can be: + C.SERVICE: service is required + C.NODE: node is required + C.SINGLE_ITEM: only one item is allowed + @attribute need_loop(bool): to set by commands when loop is needed + """ + self.need_loop = False # to be set by commands when loop is needed + try: # If we have subcommands, host is a CommandBase and we need to use host.host + self.host = host.host + except AttributeError: + self.host = host + + # --profile option + parents = kwargs.setdefault('parents', set()) + if use_profile: + #self.host.parents['profile'] is an ArgumentParser with profile connection arguments + if need_connect is None: + need_connect = True + parents.add(self.host.parents['profile' if need_connect else 'profile_session']) + else: + assert need_connect is None + self.need_connect = need_connect + # from this point, self.need_connect is None if connection is not needed at all + # False if session starting is needed, and True if full connection is needed + + # --output option + if use_output: + if extra_outputs is None: + extra_outputs = {} + self.extra_outputs = extra_outputs + if use_output == True: + use_output = C.OUTPUT_TEXT + assert use_output in C.OUTPUT_TYPES + self._output_type = use_output + output_parent = argparse.ArgumentParser(add_help=False) + choices = set(self.host.getOutputChoices(use_output)) + choices.update(extra_outputs) + if not choices: + raise exceptions.InternalError("No choice found for {} output type".format(use_output)) + try: + default = self.host.default_output[use_output] + except KeyError: + if u'default' in choices: + default = u'default' + elif u'simple' in choices: + default = u'simple' + else: + default = list(choices)[0] + output_parent.add_argument('--output', '-O', choices=sorted(choices), default=default, help=_(u"select output format (default: {})".format(default))) + output_parent.add_argument('--output-option', '--oo', type=unicode_decoder, action="append", dest='output_opts', default=[], help=_(u"output specific option")) + parents.add(output_parent) + else: + assert extra_outputs is None + + self._use_pubsub = kwargs.pop('use_pubsub', False) + if self._use_pubsub: + flags = kwargs.pop('pubsub_flags', []) + defaults = kwargs.pop('pubsub_defaults', {}) + parents.add(self.host.make_pubsub_group(flags, defaults)) + self._pubsub_flags = flags + + # other common options + use_opts = {k:v for k,v in kwargs.iteritems() if k.startswith('use_')} + for param, do_use in use_opts.iteritems(): + opt=param[4:] # if param is use_verbose, opt is verbose + if opt not in self.host.parents: + raise exceptions.InternalError(u"Unknown parent option {}".format(opt)) + del kwargs[param] + if do_use: + parents.add(self.host.parents[opt]) + + self.parser = host.subparsers.add_parser(name, help=help, **kwargs) + if hasattr(self, "subcommands"): + self.subparsers = self.parser.add_subparsers() + else: + self.parser.set_defaults(_cmd=self) + self.add_parser_options() + + @property + def args(self): + return self.host.args + + @property + def profile(self): + return self.host.profile + + @property + def verbosity(self): + return self.host.verbosity + + @property + def progress_id(self): + return self.host.progress_id + + @progress_id.setter + def progress_id(self, value): + self.host.progress_id = value + + def progressStartedHandler(self, uid, metadata, profile): + if profile != self.profile: + return + if self.progress_id is None: + # the progress started message can be received before the id + # so we keep progressStarted signals in cache to replay they + # when the progress_id is received + cache_data = (self.progressStartedHandler, uid, metadata, profile) + try: + self.host.progress_ids_cache.append(cache_data) + except AttributeError: + self.host.progress_ids_cache = [cache_data] + else: + if self.host.watch_progress and uid == self.progress_id: + self.onProgressStarted(metadata) + self.host.loop.call_later(PROGRESS_DELAY, self.progressUpdate) + + def progressFinishedHandler(self, uid, metadata, profile): + if profile != self.profile: + return + if uid == self.progress_id: + try: + self.host.pbar.finish() + except AttributeError: + pass + self.onProgressFinished(metadata) + if self.host.quit_on_progress_end: + self.host.quitFromSignal() + + def progressErrorHandler(self, uid, message, profile): + if profile != self.profile: + return + if uid == self.progress_id: + if self.args.progress: + self.disp('') # progress is not finished, so we skip a line + if self.host.quit_on_progress_end: + self.onProgressError(message) + self.host.quitFromSignal(1) + + def progressUpdate(self): + """This method is continualy called to update the progress bar""" + data = self.host.bridge.progressGet(self.progress_id, self.profile) + if data: + try: + size = data['size'] + except KeyError: + self.disp(_(u"file size is not known, we can't show a progress bar"), 1, error=True) + return False + if self.host.pbar is None: + #first answer, we must construct the bar + self.host.pbar = progressbar.ProgressBar(max_value=int(size), + widgets=[_(u"Progress: "),progressbar.Percentage(), + " ", + progressbar.Bar(), + " ", + progressbar.FileTransferSpeed(), + " ", + progressbar.ETA()]) + self.host.pbar.start() + + self.host.pbar.update(int(data['position'])) + + elif self.host.pbar is not None: + return False + + self.onProgressUpdate(data) + + return True + + def onProgressStarted(self, metadata): + """Called when progress has just started + + can be overidden by a command + @param metadata(dict): metadata as sent by bridge.progressStarted + """ + self.disp(_(u"Operation started"), 2) + + def onProgressUpdate(self, metadata): + """Method called on each progress updata + + can be overidden by a command to handle progress metadata + @para metadata(dict): metadata as returned by bridge.progressGet + """ + pass + + def onProgressFinished(self, metadata): + """Called when progress has just finished + + can be overidden by a command + @param metadata(dict): metadata as sent by bridge.progressFinished + """ + self.disp(_(u"Operation successfully finished"), 2) + + def onProgressError(self, error_msg): + """Called when a progress failed + + @param error_msg(unicode): error message as sent by bridge.progressError + """ + self.disp(_(u"Error while doing operation: {}").format(error_msg), error=True) + + def disp(self, msg, verbosity=0, error=False, no_lf=False): + return self.host.disp(msg, verbosity, error, no_lf) + + def output(self, data): + try: + output_type = self._output_type + except AttributeError: + raise exceptions.InternalError(_(u'trying to use output when use_output has not been set')) + return self.host.output(output_type, self.args.output, self.extra_outputs, data) + + def exitCb(self, msg=None): + """generic callback for success + + optionally print a message, and quit + msg(None, unicode): if not None, print this message + """ + if msg is not None: + self.disp(msg) + self.host.quit(C.EXIT_OK) + + def errback(self, failure_, msg=None, exit_code=C.EXIT_ERROR): + """generic callback for errbacks + + display failure_ then quit with generic error + @param failure_: arguments returned by errback + @param msg(unicode, None): message template + use {} if you want to display failure message + @param exit_code(int): shell exit code + """ + if msg is None: + msg = _(u"error: {}") + self.disp(msg.format(failure_), error=True) + self.host.quit(exit_code) + + def add_parser_options(self): + try: + subcommands = self.subcommands + except AttributeError: + # We don't have subcommands, the class need to implements add_parser_options + raise NotImplementedError + + # now we add subcommands to ourself + for cls in subcommands: + cls(self) + + def run(self): + """this method is called when a command is actually run + + It set stuff like progression callbacks and profile connection + You should not overide this method: you should call self.start instead + """ + # we keep a reference to run command, it may be useful e.g. for outputs + self.host.command = self + # host._need_loop is set here from our current value and not before + # as the need_loop decision must be taken only by then running command + self.host._need_loop = self.need_loop + + try: + show_progress = self.args.progress + except AttributeError: + # the command doesn't use progress bar + pass + else: + if show_progress: + self.host.watch_progress = True + # we need to register the following signal even if we don't display the progress bar + self.host.bridge.register_signal("progressStarted", self.progressStartedHandler) + self.host.bridge.register_signal("progressFinished", self.progressFinishedHandler) + self.host.bridge.register_signal("progressError", self.progressErrorHandler) + + if self.need_connect is not None: + self.host.connect_profile(self.connected) + else: + self.start() + + def connected(self): + """this method is called when profile is connected (or session is started) + + this method is only called when use_profile is True + most of time you should override self.start instead of this method, but if loop + if not always needed depending on your arguments, you may override this method, + but don't forget to call the parent one (i.e. this one) after self.need_loop is set + """ + if not self.need_loop: + self.host.stop_loop() + self.start() + + def start(self): + """This is the starting point of the command, this method should be overriden + + at this point, profile are connected if needed + """ + pass + + +class CommandAnswering(CommandBase): + """Specialised commands which answer to specific actions + + to manage action_types answer, + """ + action_callbacks = {} # XXX: set managed action types in a dict here: + # key is the action_type, value is the callable + # which will manage the answer. profile filtering is + # already managed when callback is called + + def __init__(self, *args, **kwargs): + super(CommandAnswering, self).__init__(*args, **kwargs) + self.need_loop = True + + def onActionNew(self, action_data, action_id, security_limit, profile): + if profile != self.profile: + return + try: + action_type = action_data['meta_type'] + except KeyError: + try: + xml_ui = action_data["xmlui"] + except KeyError: + pass + else: + self.onXMLUI(xml_ui) + else: + try: + callback = self.action_callbacks[action_type] + except KeyError: + pass + else: + callback(action_data, action_id, security_limit, profile) + + def onXMLUI(self, xml_ui): + """Display a dialog received from the backend. + + @param xml_ui (unicode): dialog XML representation + """ + # FIXME: we temporarily use ElementTree, but a real XMLUI managing module + # should be available in the future + # TODO: XMLUI module + ui = ET.fromstring(xml_ui.encode('utf-8')) + dialog = ui.find("dialog") + if dialog is not None: + self.disp(dialog.findtext("message"), error=dialog.get("level") == "error") + + def connected(self): + """Auto reply to confirmations requests""" + self.need_loop = True + super(CommandAnswering, self).connected() + self.host.bridge.register_signal("actionNew", self.onActionNew) + actions = self.host.bridge.actionsGet(self.profile) + for action_data, action_id, security_limit in actions: + self.onActionNew(action_data, action_id, security_limit, self.profile)