Source code for flexx.event._handler

"""
Implementation of handler class and corresponding descriptor.
"""

import sys
import inspect
import weakref

from ._dict import Dict
from ._loop import loop
from . import logger


def this_is_js():
    return False

console = setTimeout = None


# Decorator to wrap a function in a Handler object
def connect(*connection_strings):
    """ Decorator to turn a method of HasEvents into an event
    :class:`Handler <flexx.event.Handler>`.
    
    A method can be connected to multiple event types. Each connection
    string represents an event type to connect to. Read more about
    dynamism and labels for further information on the possibilities
    of connection strings.
    
    To connect functions or methods to an event from another HasEvents
    object, use that object's
    :func:`HasEvents.connect()<flexx.event.HasEvents.connect>` method.
    
    .. code-block:: py
        
        class MyObject(event.HasEvents):
            @event.connect('first_name', 'last_name')
            def greet(self, *events):
                print('hello %s %s' % (self.first_name, self.last_name))
    """
    if (not connection_strings) or (len(connection_strings) == 1 and
                                    callable(connection_strings[0])):
        raise RuntimeError('Connect decorator needs one or more event strings.')
    
    func = None
    if callable(connection_strings[0]):
        func = connection_strings[0]
        connection_strings = connection_strings[1:]
    
    for s in connection_strings:
        if not (isinstance(s, str) and len(s) > 0):
            raise ValueError('Connection string must be nonempty strings.')
    
    def _connect(func):
        if not callable(func):
            raise TypeError('connect() decorator requires a callable.')
        return HandlerDescriptor(func, connection_strings)
    
    if func is not None:
        return _connect(func)
    else:
        return _connect


class HandlerDescriptor:
    """ Class descriptor for handlers.
    """
    
    def __init__(self, func, connection_strings):
        assert callable(func)  # HandlerDescriptor is not instantiated directly
        self._func = func
        self._name = func.__name__  # updated by HasEvents meta class
        self._connection_strings = connection_strings
        self.__doc__ = '*%s*: %s' % ('event handler', func.__doc__ or self._name)
    
    def __repr__(self):
        t = '<%s %r(this should be a class attribute) at 0x%x>'
        return t % (self.__class__.__name__, self._name, id(self))
        
    def __set__(self, obj, value):
        raise AttributeError('Cannot overwrite handler %r.' % self._name)
    
    def __delete__(self, obj):
        raise AttributeError('Cannot delete handler %r.' % self._name)
    
    def __get__(self, instance, owner):
        if instance is None:
            return self
        
        private_name = '_' + self._name + '_handler'
        try:
            handler = getattr(instance, private_name)
        except AttributeError:
            handler = Handler(self._func, self._connection_strings, instance)
            setattr(instance, private_name, handler)
        
        # Make the handler use *our* func one time. In most situations
        # this is the same function that the handler has, but not when
        # using super(); i.e. this allows a handler to call the same
        # handler of its super class.
        handler._use_once(self._func)
        return handler
    
    @property
    def local_connection_strings(self):
        """ List of connection strings that are local to the object.
        """
        return [s for s in self._connection_strings if '.' not in s]


class Handler:
    """ Wrapper around a function object to connect it to one or more events.
    This class should not be instantiated directly; use ``event.connect`` or
    ``HasEvents.connect`` instead.
    
    Arguments:
        func (callable): function that handles the events.
        connection_strings (list): the strings that represent the connections.
        ob (HasEvents): the HasEvents object to use a a basis for the
            connection. A weak reference to this object is stored. It
            is passed a a first argument to the function in case its
            first arg is self.
    """
    
    _count = 0
    
    def __init__(self, func, connection_strings, ob):
        # Check and set func
        assert callable(func)
        self._func = func
        self._func_once = func
        self._name = func.__name__
        Handler._count += 1
        self._id = 'h%i' % Handler._count  # to ensure a consistent event order
        
        # Set docstring; this appears correct in sphinx docs
        self.__doc__ = '*%s*: %s' % ('event handler', func.__doc__ or self._name)
        
        # Store object using a weakref
        self._ob = weakref.ref(ob)
        
        # Get whether function is a method
        try:
            self._func_is_method = inspect.getargspec(func)[0][0] in ('self', 'this')
        except (TypeError, IndexError):
            self._func_is_method = False
        if getattr(func, '__self__', None) is not None:
            self._func_is_method = False  # already bound
        
        self._init(connection_strings)
    
    
    def _init(self, connection_strings):
        """ Init of this handler that is compatible with PyScript.
        """
        # Init connections
        self._connections = []
        for s in connection_strings:
            d = Dict()  # don't do Dict(foo=x) bc PyScript only supports that for dict
            self._connections.append(d)
            d.fullname = s
            d.type = s.split('.')[-1]
            d.objects = []
        
        # Pending events for this handler
        self._scheduled_update = False
        self._pending = []  # pending events
        
        # Connect
        for index in range(len(self._connections)):
            self._connect_to_event(index)
    
    def __repr__(self):
        c = '+'.join([str(len(c.objects)) for c in self._connections])
        cname = self.__class__.__name__
        return '<%s %r with %s connections at 0x%x>' % (cname, self._name, c, id(self))
    
    def get_name(self):
        """ Get the name of this handler, usually corresponding to the name
        of the function that this handler wraps.
        """
        return self._name
    
    def get_connection_info(self):
        """ Get a list of tuples (name, connection_names), where
        connection_names is a list of type names (including label) for
        the made connections.
        """
        return [(c.fullname, [u[1] for u in c.objects])
                for c in self._connections]
    
    ## Calling / handling
    
    def _use_once(self, func):
        self._func_once = func
    
    def __call__(self, *events):
        """ Call the handler function.
        """
        func = self._func_once
        if self._func_is_method and self._ob is not None:
            res = func(self._ob(), *events)
        else:
            res = func(*events)
        self._func_once = self._func
        return res
    
    def _add_pending_event(self, label, ev):
        """ Add an event object to be handled at the next event loop
        iteration. Called from HasEvents.emit().
        """
        if not self._scheduled_update:
            # register only once
            self._scheduled_update = True
            if this_is_js():
                setTimeout(self._handle_now_callback.bind(self), 0)
            else:
                loop.call_later(self._handle_now_callback)
        self._pending.append((label, ev))
    
    def _handle_now_callback(self):
        self._scheduled_update = False
        self.handle_now()
    
    def handle_now(self):
        """ Invoke a call to the handler function with all pending
        events. This is normally called in a next event loop iteration
        when an event is scheduled for this handler, but it can also
        be called manually to force the handler to process pending
        events *now*.
        """
        # Collect pending events and check what connections need to reconnect
        events = []
        reconnect = []
        for label, ev in self._pending:
            if label.startswith('reconnect_'):
                index = int(label.split('_')[-1])
                reconnect.append(index)
            else:
                events.append(ev)
        self._pending = []
        # Reconnect (dynamism)
        for index in reconnect:
            self._connect_to_event(index)
        # Handle events
        if len(events):
            if not this_is_js():
                logger.debug('Handler %s is processing %i events' %
                            (self._name, len(events)))
            try:
                self(*events)
            except Exception as err:
                if this_is_js():
                    console.log(err)
                else:
                    # Allow post-mortem debugging
                    type_, value, tb = sys.exc_info()
                    tb = tb.tb_next  # Skip *this* frame
                    sys.last_type = type_
                    sys.last_value = value
                    sys.last_traceback = tb
                    tb = None  # Get rid of it in this namespace
                    # Show the exception
                    logger.exception(value)
    
    
    ## Connecting
    
    def dispose(self):
        """ Cleanup any references.
        
        Disconnects all connections, and cancel all pending events.
        """
        if not this_is_js():
            logger.debug('Disposing Handler %r ' % self)
        for connection in self._connections:
            while len(connection.objects):
                ob, name = connection.objects.pop(0)
                ob.disconnect(name, self)
        while len(self._pending):
            self._pending.pop()  # no list.clear on legacy py
    
    def _clear_hasevents_refs(self, ob):
        """ Clear all references to the given HasEvents instance. This is
        called from a HasEvents' dispose() method. This handler remains
        working, but wont receive events from that object anymore.
        """
        for connection in self._connections:
            for i in reversed(range(len(connection.objects))):
                if connection.objects[i][0] is ob:
                    connection.objects.pop(i)
        
        # Do not clear pending events. This handler is assumed to continue
        # working, and should thus handle its pending events at some point,
        # at which point it cannot hold any references to ob anymore.
    
    def _connect_to_event(self, index):
        """ Connect one connection.
        """
        connection = self._connections[index]
        
        # Disconnect
        while len(connection.objects):
            ob, name = connection.objects.pop(0)
            ob.disconnect(name, self)
        
        path = connection.fullname.split('.')[:-1]
        
        # Obtain root object and setup connections
        ob = self._ob()
        if ob is not None:
            self._seek_event_object(index, path, ob)
        
        # Verify
        if not connection.objects:
            raise RuntimeError('Could not connect to %r' % connection.fullname)
        
        # Connect
        for ob, type in connection.objects:
            ob._register_handler(type, self)
    
    def _seek_event_object(self, index, path, ob):
        """ Seek an event object based on the name (PyScript compatible).
        """
        connection = self._connections[index]
        
        # Done traversing name: add to list or fail
        if ob is None or not len(path):
            if ob is None or not hasattr(ob, '_IS_HASEVENTS'):
                return  # we cannot seek further
            connection.objects.append((ob, connection.type))
            return  # found it
        
        # Resolve name
        obname, path = path[0], path[1:]
        if hasattr(ob, '_IS_HASEVENTS') and obname in ob.__properties__:
            name_label = obname + ':reconnect_' + str(index)
            connection.objects.append((ob, name_label))
            ob = getattr(ob, obname, None)
        elif obname == '*' and isinstance(ob, (tuple, list)):
            for sub_ob in ob:
                self._seek_event_object(index, path, sub_ob)
            return
        else:
            ob = getattr(ob, obname, None)
        return self._seek_event_object(index, path, ob)