# lyxclient.py         -*- coding: iso-8859-1 -*-
# Copyright (c) 2005 Günter Milde
# Released under the terms of the GNU General Public License (ver. 2 or later)
"""
Classes and functions to implement a generic client talking to LyX via the 
lyxserver pipes
   
   LyXClient     LyX client using the serverpipes
                 More highlevel than the lyxserver.LyXServer class
                 
   LyXMessage    Message from/for the lyxserver
                 
   LyXError      Exception class for errors returned by LyX
                      
   lyx_remote()  open files in a running lyx session (or start a new one)


Needs the lyxserver running 
     Set Edit>Preferences>Paths>Server-pipe to "~/.lyx/lyxpipe"
     
Copyright (c) 2005 Günter Milde
Released under the terms of the GNU General Public License (ver. 2 or later)

Notes
-----
   
   * Server documentation (slightly outdated) is available at
      LYXDIR/doc/Customization.lyx (but not in the German translation)
   
   * A list of all LFUNS is available at 
         http://wiki.lyx.org/pmwiki.php/LyX/LyxFunctions
"""

import sys, os, logging
from lyxserver import LyXServer, start_lyx
from constants import LOG_LEVEL, LYX_OPTIONS, LYXCMD, \
                      LYXSERVER_POLL_TIMEOUT, LYXSERVER_SETUP_RETRIES

# set up the logger instance
logger = logging.getLogger("lyxclient")
# logging.basicConfig()  # done in lyxserver.py
# set verbosity to show all messages of severity >= LOG_LEVEL
logger.setLevel(LOG_LEVEL) 

class LyXMessage(dict):
    """Message from/for the lyxserver pipes
    
    Stores the parts of the message in the attributes
        msg_type   -- type of the message
        client     -- name that the client can choose arbitrarily
        function   -- function you want LyX to perform (lyx-function, LFUN)
                      (The same as the commands you'd use in the minibuffer)
        data       -- optional data (function argument, return value, 
                      error message, or notifying key)
       
    Initializing
        positional argument -- parsed as message string, or
        keyword arguments   -- set matching attributes
    
    Converting the object to a message string (suitable for writing to the 
    serverpipe, with newline at the end) 
        str(LyXMessage_instance)
        
    Examples:
        str(LyXMessage("LYXCMD:name:fun:arg"))
        str(LyXMessage(msg_type='LYXCMD', client='name', function='fun', 
                       data='arg'))
        str(LyXMessage("LYXCMD:name:fun:arg", client='another-name'))
    all give
    >>> 'LYXCMD:name:fun:arg\n'
    while
        str(LyXMessage("NOTIFY:key-sequence"))
        str(LyXMessage("NOTIFY:key-sequence:unread junk"))
        str(LyXMessage(type="NOTIFY", data="key-sequence"))
    give
    >>> 'NOTIFY:key-sequence\n'
    and
        str(LyXMessage(type="NOTIFY", function="fun", data="key"))
    would result in the malformed message string.
    >>> 'NOTIFY:fun:key\n'
    """
    # Valid message types and recognized fields (as of LyX 1.3.4)
    # ordered list of all message fields
    fieldnames = ("msg_type", "client", "function", "data")
    msg_types = {"LYXCMD": fieldnames,
                  "INFO": fieldnames, 
                  "ERROR": fieldnames, 
                  "NOTIFY":("msg_type", "data"), 
                  "LYXSRV":("msg_type", "client", "data")
                 }
    # 
    def __init__(self, msg_string='', **keywords):
        """Parse `msg_string` or set matching attributes.
        """
        self.__dict__ = self  # dictionary elements are also attributes
        if msg_string:
            self.parse(msg_string)
        else:
            dict.__init__(self, keywords)
    # 
    def parse(self, msg_string):
        """Parse a message string and set attributes
        """
        self.clear() # delete old attributes
        # strip trailing newline and split (into <= 4 parts)
        values = msg_string.rstrip().split(':', 3)
        # get field names for this message type
        names = self.msg_types[values[0]] # values[0] holds msg_type
        fields = dict(zip(names, values))
        dict.__init__(self, fields)

    #
    def list_fields(self):
        """Return ordered list of field values
        
        The order is %s
        Empty fields get the value None
        """%str(self.fieldnames)
        return [self.get(name) for name in self.fieldnames]
    #
    def __str__(self):
        """Return a string representation suitable to pass to the lyxserver
        """
        values = filter(bool, self.list_fields()) # filter empty fields
        return ':'.join(values)+'\n'
    #
    def __repr__(self):
        """Return an evaluable representation"""
        args = ["%s=%s"%(name, value) for (name, value) in self.iteritems()]
        return "LyXMessage(%s)"%", ".join(args)
    #
    def __eq__(self, other):
        """Test for equality. 
        
        Two Messages are equal, if all fields match. A field value of True 
        serves as wildcard
        
        Examples:
          LyXMessage("NOTIFY:F12") == LyXMessage("NOTIFY:F12:")
          >>> True
          LyXMessage("NOTIFY:F12") == LyXMessage("NOTIFY:F11")
          >>> False
          LyXMessage("NOTIFY:F12") == LyXMessage(msg_type="NOTIFY", data=True)
          >>> True
        """
        # TODO: test if filtering True values from self.list_fields() is better
        for name in self.fieldnames:
            if self.get(name) is True or other.get(name) is True:
                continue
            if self.get(name) != other.get(name):
                return False
        return True
    #
    def __ne__(self, other):
        # needs to be defined separately, see Python documentation
        return not self.__eq__(other)

class LyXError(Exception):
    """Exception class for errors returned by LyX"""
    def __init__(self, strerror):
        self.strerror = strerror
    def __str__(self):
        return self.strerror


class LyXClient(LyXServer):
    """A client that connects to a LyX session via the serverpipes
    
    Adds lyx-function calls, message parsing and listening to the LyXServer
        
    Calling an instance, sends `function` as a function call to LyX 
    and returns the reply data (an ERROR reply raises a LyXError)
    """
    #
    name = "pyClient"
    bindings = {} # bindings of "notify" keys to actions
    #
    def open(self):
        """Open the server pipes and register at LyX"""
        LyXServer.open(self)
        self.write('LYXSRV:%s:hello\n'%self.name)
        logger.info(self.readmessage(msg_type='LYXSRV'))
    #
    def close(self):
        """Unregister at LyX and close the server pipes"""
        # Unregister at LyX (no response will be sent)
        try:
            self.inpipe.write('LYXSRV:%s:bye\n'%self.name)
        except AttributeError:
            pass
        LyXServer.close(self)
    #
    def readmessage(self, timeout=None,
                    msg_type=True, client=True, function=True, data=True,
                    writeback=0):
        """Read one line of outpipe, return as LyXMessage.
        (optionally, check for matching fields)
        from the outpipe, 
        
        timeout              -- polling timeout (in ms)
                                not-set or None -> use self.timeout
        msg_type, client,    -- if one of these is not None, the next message
        function, data          with matching field value is picked           
        writeback            -- write back last n filtered messages
                                (allows several clients to work in parallel)
        """
        # template to check the messages against (default: all messages match)
        pattern = LyXMessage(msg_type=msg_type, client=client, 
                             function=function, data=data)
        junkmessages = []  # leftovers from message picking
        # logger.debug("readmessage: pattern " + repr(pattern))
        for msg in self.__iter__(timeout):
            # logger.debug("readmessage: testing " + repr(msg))
            if msg == pattern:
                # logger.debug("readmessage: match " + str(msg).strip())
                break
            elif msg: # junk message
                logger.debug("readmessage: junk message " + str(msg).strip())
                junkmessages.append(msg)
            else:
                logger.warning("readmessage: no match found")
                msg = LyXMessage() # empty message
        else: # empty outpipe
            logger.warning("readmessage: timed out")
            msg = LyXMessage() # empty message
        # write back junk messages
        if writeback and junkmessages:
            logger.debug("readmessage: write back last (<=%d) messages"%writeback)
            # logger.debug(str(junkmessages[-writeback:]))
            self._writeback(junkmessages[-writeback:])
        return msg
    # 
    def readmessages(self, timeout=None):
        """Read waiting messages. Return list of `LyXMessage` objects.
        """
        return map(LyXMessage, self.readlines(timeout=None))
    #
    def _writeback(self, messages):
        """Write sequence of messages back to the outpipe"""
        lines = map(str, messages)
        # read waiting messages, to preserve the order
        # in an ideal world, the next 2 lines should be an atomic action
        lines.extend(self.readlines(timeout=0))
        self.outpipe.writelines(lines)
    #
    def write_lfun(self, function, *args):
        """send a LFUN to the inpipe"""
        msg = LyXMessage(msg_type='LYXCMD', client = self.name,
                         function = function, data = ' '.join(map(str, args)))
        self.write(msg)
    #
    # iterator protocoll
    def __iter__(self, timeout=None):
        """Return iterator (generator) yielding `LyXMessage(self.readline)`
        
        See `LyXServer.readline` for discussion of the `timeout`
        argument.
              
        Example: 
            1. simple call with default timout (self.timeout):
                for msg in instance:
                    print msg
            2. call with custom timeout:
                for msg in instance.__iter__(timeout=20):
                    print msg
        """
        while self.poll(timeout):
            msg = LyXMessage(self.outpipe.readline())
            # stop iterating if lyx died (saves waiting for `timeout` ms)
            if msg.msg_type == "LYXSRV" and msg.data == "bye":
                logger.info("LyX said bye, closing serverpipes")
                raise LyXError("LyX closed down")
            yield msg
    #
    # direct call of instance 
    def __call__(self, function, *args):
        """send a LFUN to the inpipe and return the reply data"""
        self.write_lfun(function, *args)
        # read reply
        logger.debug("LyXClient.__call__:%r sent, waiting for reply"%function)
        reply = self.readmessage(timeout=self.timeout,  client=self.name, 
                                 function=function, writeback=5)
        if not reply:
            logger.warning(function + ": no reply")
            return None
        # logger.debug("__call__: reply string: %r"%(reply))
        if reply.msg_type == 'ERROR':
            raise LyXError, ':'.join((reply.function, reply.data))
        else:
            return reply.data
    #
    def listen(self, timeout=60000):
        """wait for a NOTIFY from LyX, run function bound to key
        """
        self.timeout = timeout
        for msg in self:
            logger.debug("listen: new message '%s'"%(str(msg)))
            if msg.msg_type == 'NOTIFY':
                logger.info("listen: notice from key '%s'"%msg.data)
                # call the key binding
                try:
                    fun = self.bindings[msg.data]
                except KeyError:
                    logger.warning("key %s not bound"%msg.data)
                    self("message key %s not bound"%msg.data)
                    continue
                try:
                    if type(fun) is str:
                        exec fun
                    else:
                        fun()
                except LyXError, exception:
                    logger.warning(exception.strerror)
                    self("message", exception.strerror)
            else:
                logger.debug("listen: junk message '%s'"%(line))
                # TODO: write back, wait a bit (for others to pick up)
                # and continue (discarding msg the second time)
        else:
            logger.critical("listen timed out")
    #
    def __del__ (self):
        """Unregister at LyX and close the server pipes"""
        logger.info("deleting "+self.name)
        self.close()


# lyx-remote: Open files in a lyx session 
# ---------------------------------------

def filter_options(args, valid_options):
    """parse a list of arguments and filter the options
                                                                         
    args          -- list of command parameters (e.g. sys.argv[1:])
    valid options -- dictionary of accepted options with number of 
                     option-parameters as value
    
    Note: using getopt doesnot work due to 'one-dash long-options' :-(
    (opts, files) = getopt.getopt(['-help'], LYX_OPTIONS, lyx_long_options)
    Also the new 'optparse' module doesnot support these.
    """
    option_parameters = 0
    options = []
    filenames = []
    for arg in args:
        if option_parameters:
            options.append(arg)
            option_parameters -= 1
        elif arg in valid_options:
            options.append(arg)
            option_parameters = valid_options[arg]
        else:
            filenames.append(arg)
    return options, filenames


def lyx_remote(cmd=LYXCMD, args=sys.argv[1:]):
    """Open all files in `args` in a lyx session.
    
    Check for a running LyX session and let it open the files
    or start LyX in a separate process.
    
    cmd  -- lyx binary command name (searched on PATH)
    args -- list of command parameters excluding the command name
            (default sys.argv[1:])
            
    Return LyXServer instance 
    """
    # separate command line options (+ arguments) and filenames
    options, filenames = filter_options(args, LYX_OPTIONS)
    # logger.debug("lyx_remote:options:%r,filename:%r"%(options, filenames))
    # start a new lyx if there are command line options but no files to open
    if options and not filenames:
        start_lyx(cmd, options)
    
    # move the options (+ option args) to the cmd
    cmd = " ".join([cmd]+ options)
    
    client = LyXClient(lyxcmd=cmd)
    print cmd
    # Send a LYXCMD for every filename argument
    for filename in filenames:
        logger.debug("lyx-remote: opening %s"%filename)
        client("file-open", os.path.abspath(filename))
    return client
