view frontends/src/jp/base.py @ 1858:06e13ae616cf

tools (utils): improved repository version detection: - getRepositoryData has 2 new option: is_path to give absolute path instead of module name, and save_dir_path to save the repository data in a pickled dictionnary - if "hg" executable doesn't work, a .hg_data dictionnary is checked if present in module root path
author Goffi <goffi@goffi.org>
date Sun, 28 Feb 2016 13:42:31 +0100
parents 9eabf7fadfdd
children afc7f6328394
line wrap: on
line source

#! /usr/bin/python
# -*- coding: utf-8 -*-

# jp: a SAT command line tool
# Copyright (C) 2009-2016 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 gi.repository import GLib
from glob import iglob
from importlib import import_module
from sat_frontends.tools.jid import JID
from sat_frontends.bridge.DBus import DBusBridgeFrontend
from sat.core import exceptions
import sat_frontends.jp
from sat_frontends.jp.constants import Const as C
import xml.etree.ElementTree as ET  # FIXME: used temporarily to manage XMLUI

try:
    import progressbar
except ImportError:
    log.info (_('ProgressBar not available, please download it at http://pypi.python.org/pypi/progressbar'))
    log.info (_('Progress bar deactivated\n--\n'))
    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-2016 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 need_loop(bool): to set by commands when loop is needed
        @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
        """
        try:
            self.bridge = DBusBridgeFrontend()
        except exceptions.BridgeExceptionNoService:
            print(_(u"Can't connect to SàT backend, are you sure it's launched ?"))
            sys.exit(1)
        except exceptions.BridgeInitError:
            print(_(u"Can't init bridge"))
            sys.exit(1)

        self.parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter,
                                              description=DESCRIPTION)

        self._make_parents()
        self.add_parser_options()
        self.subparsers = self.parser.add_subparsers(title=_('Available commands'), dest='subparser_name')
        self._auto_loop = False # when loop is used for internal reasons
        self.need_loop = False # to set by commands when loop is needed

        # progress attributes
        self._progress_id = None # TODO: manage several progress ids
        self.quit_on_progress_end = True

    @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):
        """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
        """
        if self.verbosity >= verbosity:
            if error:
                print >>sys.stderr,msg
            else:
                print msg

    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 _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, 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)"))

    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 import_commands(self):
        """ Automaticaly import commands to jp
        looks from modules names cmd_*.py in jp path and import them

        """
        path = os.path.dirname(sat_frontends.jp.__file__)
        modules = (os.path.splitext(module)[0] for module in map(os.path.basename, iglob(os.path.join(path, "cmd_*.py"))))
        for module_name in modules:
            module = import_module("sat_frontends.jp."+module_name)
            try:
                self.import_command_module(module)
            except ImportError:
                continue

    def import_command_module(self, module):
        """ Add commands from a module to jp
        @param module: module containing commands

        """
        try:
            for classname in module.__commands__:
                cls = getattr(module, classname)
        except AttributeError:
            log.warning(_("Invalid module %s") % module)
            raise ImportError
        cls(self)

    def run(self, args=None):
        self.args = self.parser.parse_args(args)
        self.args.func()
        if self.need_loop or self._auto_loop:
            self._start_loop()

    def _start_loop(self):
        self.loop = GLib.MainLoop()
        try:
            self.loop.run()
        except KeyboardInterrupt:
            log.info(_("User interruption: good bye"))

    def stop_loop(self):
        try:
            self.loop.quit()
        except AttributeError:
            pass

    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 with an error code
        # so we use this little timeout trick to avoid it
        GLib.timeout_add(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()
        if errcode:
            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.getProfileName(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.asyncConnect(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_progress=False, use_verbose=False, 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_progress(bool): if True, add progress bar activation commands
            progress* signals will be handled
        @param use_verbose(bool): if True, add verbosity command
        @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

        """
        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

        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

        if use_progress:
            parents.add(self.host.parents['progress'])

        if use_verbose:
            parents.add(self.host.parents['verbose'])

        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(func=self.run)
        self.add_parser_options()

    @property
    def args(self):
        return self.host.args

    @property
    def need_loop(self):
        return self.host.need_loop

    @need_loop.setter
    def need_loop(self, value):
        self.host.need_loop = value

    @property
    def profile(self):
        return self.host.profile

    @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)
                GLib.timeout_add(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.quit()

    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(int(size),
                                                    [_(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):
        return self.host.disp(msg, verbosity, error)

    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):
        try:
            if self.args.profile:
                connect_profile = self.host.connect_profile
        except AttributeError:
            # the command doesn't need to connect profile
            pass
        else:
            connect_profile(self.connected)

        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("progressStarted", self.progressStartedHandler)
            self.host.bridge.register("progressFinished", self.progressFinishedHandler)
            self.host.bridge.register("progressError", self.progressErrorHandler)

    def connected(self):
        if not self.need_loop:
            self.host.stop_loop()


class CommandAnswering(CommandBase):
    """Specialised commands which answer to specific actions

    to manage action_types answer,
    """
    action_callbacks = {} # XXX: set managed action types in an 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 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("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)