Source code for mcl.network.abstract

"""Interface specification for publishing and receiving data in MCL.

This module defines an interface for publishing and receiving data in MCL. This
is done by providing abstract objects for broadcasting and listening for
data. The interface defined by these objects helps insure new interface
implementations will integrate with MCL.

The following abstract objects are defined:

    - :class:`~.abstract.Connection`
    - :class:`.RawBroadcaster`
    - :class:`.RawListener`

For examples of how to use :mod:`.abstract` to integrate a new network
interface into MCL see :mod:`.network.udp`.

.. sectionauthor:: Asher Bender <a.bender@acfr.usyd.edu.au>
.. codeauthor:: Asher Bender <a.bender@acfr.usyd.edu.au>

"""
import abc
import sys
import keyword
import operator
import textwrap
import mcl.event.event


class _ConnectionMeta(type):
    """Meta-class for manufacturing network interface connection objects.

    The :class:`._ConnectionMeta` object is a meta-class designed to
    manufacture MCL network interface :class:`~.abstract.Connection`
    classes. Connection objects behave like namedtuples. The meta-class works
    by dynamically adding mandatory and optional attributes to a class
    definition at run time if and ONLY if the class inherits from
    :class:`~.abstract.Connection`.

    Classes that inherit from :class:`~.abstract.Connection` must implement the
    attributes `mandatory`, `broadcaster` and `listener` where:

        - `mandatory` is a list of strings defining the names of mandatory
          message attributes that must be present when instances of the new
          :class:`.Message` objects are created. During instantiation the input
          list *args is mapped to the attributes defined by `mandatory`. If
          `mandatory` is not present, a TypeError will be raised.

        - `broadcaster` is a reference to the :class:`.RawBroadcaster` object
          associated with the :class:`~.abstract.Connection` object.

        - `listener` is a reference to the :class:`.RawListener` object
          associated with the :class:`~.abstract.Connection` object.

    Classes that inherit from :class:`~.abstract.Connection` can optionally
    implement the `optional` attribute where:

        - `optional` is a dictionary of optional connection parameters and
          their defaults. Keywords represent attribute names and the
          corresponding value represents the default value. During
          instantiation of the new Connection object, **kwargs is mapped to the
          attributes defined by `optional`. Note that `optional` is not
          required.

    These attributes are used to manufacture an object to contain the
    definition. See :class:`~.abstract.Connection` for implementation detail.

    Note that classes that do not inherit from :class:`~.abstract.Connection`
    will be left unmodified. These are the :class:`~.abstract.Connection`
    object and objects which sub-class a sub-class of
    :class:`~.abstract.Connection`.

    Raises:
        TypeError: If the parent class is a :class:`~.abstract.Connection`
            object or any of the mandatory or optional attributes are
            ill-specified.
        ValueError: If any of the mandatory or optional attribute names are
            ill-specified.

    """

    def __new__(cls, name, bases, dct):
        """Manufacture a network interface connection class.

        Manufacture a network interface class for objects inheriting from
        :class:`~.abstract.Connection`. This is done by searching the input
        dictionary `dct` for the keys `mandatory` and `optional` where:

            - `mandatory` is a list of strings defining the names of mandatory
              message attributes that must be present when instances of the new
              :class:`.Message` objects are created. During instantiation the
              input list *args is mapped to the attributes defined by
              `mandatory`. If `mandatory` is not present, a TypeError will be
              raised.

            - `broadcaster` is a reference to the :class:`.RawBroadcaster`
              object associated with the :class:`~.abstract.Connection`
              object.

            - `listener` is a reference to the :class:`.RawListener` object
              associated with the :class:`~.abstract.Connection` object.

            - `optional` is a dictionary of optional connection parameters and
              their defaults. Keywords represent attribute names and the
              corresponding value represents the default value. During
              instantiation of the new Connection object, the input dictionary
              **kwargs is mapped to the attributes defined by
              `optional`. `optional` is not required.

        A new connection class is manufactured using the definition specified
        by the attributes. Note that none of the attribute names can be set to
        `mandatory`, `broadcaster` or `listener`.

        Args:
          cls (class): is the class being instantiated.
          name (string): is the name of the new class.
          bases (tuple): base classes of the new class.
          dct (dict): dictionary mapping the class attribute names to objects.

        Returns:
            :class:`~.abstract.Connection`: sub-class of
                :class:`~.abstract.Connection` with attributes defined by the
                original `mandatory` and `optional` attributes.

        Raises:
            TypeError: If the mandatory or optional attributes are
                ill-specified.
            ValueError: If any of the mandatory or optional attribute names are
                ill-specified.

        """

        # NOTE: This code essentially manufactures a 'namedtuple' object using
        #       code adapted from the python library:
        #
        #           https://docs.python.org/2/library/collections.html#collections.namedtuple
        #
        #       This allows the attributes in the object to be immutable
        #       (read-only) one created. Note that all of the objects that are
        #       manufactured also inherit from the Connection() class.

        # Do not look for 'mandatory'/'optional' attributes in the Connection()
        # base class.
        if (name == 'Connection') and (bases == (tuple,)):
            return super(_ConnectionMeta, cls).__new__(cls, name, bases, dct)

        #  Do not look for 'mandatory'/'optional' attributes in sub-classes of
        # the Connection() base class.
        elif bases != (Connection,):
            return super(_ConnectionMeta, cls).__new__(cls, name, bases, dct)

        # Objects inheriting from Connection() are required to have a
        # 'mandatory' attribute. The 'optional' and 'docstring' are optional.
        mandatory = dct.get('mandatory', {})
        broadcaster = dct.get('broadcaster', None)
        listener = dct.get('listener', None)
        optional = dct.get('optional', {})

        # Ensure 'mandatory' is a list or tuple of strings.
        if (((not isinstance(mandatory, (list, tuple))) or
             (not all(isinstance(item, basestring) for item in mandatory)))):
            msg = "'mandatory' must be a string or a list/tuple or strings."
            raise TypeError(msg)

        # Ensure 'broadcaster' is a RawBroadcaster() object.
        if not broadcaster or not issubclass(broadcaster, RawBroadcaster):
            msg = "'broadcaster' must reference a RawBroadcaster() sub-class."
            raise TypeError(msg)

        # Ensure 'listener' is a RawListener() object.
        if not listener or not issubclass(listener, RawListener):
            msg = "'listener' must reference a RawListener() sub-class."
            raise TypeError(msg)

        # Ensure 'optional' is a list or tuple.
        if not isinstance(optional, (dict,)):
            msg = "'optional' must be a dictionary."
            raise TypeError(msg)

        # Ensure all keys in 'optional' are a string.
        if not all(isinstance(key, basestring) for key in optional.keys()):
            msg = "All keys in 'optional' must be strings."
            raise TypeError(msg)

        # Add optional fields.
        attrs = tuple(list(mandatory) + list(optional.keys()))

        # Parse and validate the field names. Validation serves two purposes,
        # generating informative error messages and preventing template
        # injection attacks.
        for attr in (name,) + attrs:
            if not all(c.isalnum() or c == '_' for c in attr):
                msg = 'Type names and field names can only contain '
                msg += 'alphanumeric characters and underscores: %r'
                raise ValueError(msg % attr)

            if keyword.iskeyword(attr):
                msg = 'Type names and field names cannot be a keyword: %r'
                raise ValueError(msg % attr)

            if attr[0].isdigit():
                msg = 'Type names and field names cannot start with a number: '
                msg += '%r'
                raise ValueError(msg % attr)

        # Detect duplicate attribute names.
        invalid = ['_mandatory', '_optional', 'broadcaster', 'listener', ]
        seen_attr = set()
        for attr in attrs:
            if attr in invalid:
                msg = "Field names cannot be %r." % invalid
                raise ValueError(msg)
            if attr.startswith('_'):
                msg = 'Field names cannot start with an underscore: %r' % attr
                raise ValueError(msg)
            if attr in seen_attr:
                raise ValueError('Encountered duplicate field name: %r' % attr)
            seen_attr.add(attr)

        # Create 'prototype' for defining a new object.
        numfields = len(attrs)
        inputtxt = ', '.join(mandatory)
        if optional:
            for key, value in optional.iteritems():
                inputtxt += ", %s=%r" % (key, value)

        # Create strings for arguments and printing.
        argtxt = repr(attrs).replace("'", "")[1:-1]
        reprtxt = ', '.join('%s=%%r' % attr for attr in attrs)

        # Create mapping object (key-value pairs).
        dicttxt = ['%r: t[%d]' % (n, p) for p, n in enumerate(attrs)]
        dicttxt = ', '.join(dicttxt)

        def execute_template(template, key, namespace={}):

            template = textwrap.dedent(template)
            try:
                exec template in namespace
            except SyntaxError, e:
                raise SyntaxError(e.message + ':\n' + template)

            return namespace[key]

        __new__ = execute_template("""
        def __new__(cls, %s):
            return tuple.__new__(cls, (%s))
        """ % (inputtxt, argtxt), '__new__')

        _make = execute_template("""
        @classmethod
        def _make(cls, iterable, new=tuple.__new__, len=len):
            'Make a new %s object from a sequence or iterable'

            result = new(cls, iterable)
            if len(result) != %d:
                msg = 'Expected %d arguments, got %%d' %% len(result)
                raise TypeError(msg)
            return result
        """ % (name, numfields, numfields), '_make')

        __repr__ = execute_template("""
        def __repr__(self):
            return '%s(%s)' %% self
        """ % (name, reprtxt), '__repr__')

        to_dict = execute_template("""
        def to_dict(t):
            'Return a new dict which maps field names to their values'

            return {%s}
        """ % (dicttxt), 'to_dict')

        from_dict = execute_template("""
        @classmethod
        def from_dict(cls, dictionary):
            '''Make a new %s object from a dictionary

            If optional attributes are not specified, their default values are
            used.
            '''

            # Gather mandatory attributes.
            args = list()
            for attr in %r:
               if attr not in dictionary:
                    msg = "Expected the attribute: '%%s'." %% attr
                    raise AttributeError(msg)
               else:
                   args.append(dictionary[attr])

            # Gather optional attributes.
            for attr, value in cls._optional.iteritems():
               if attr in dictionary:
                   args.append(dictionary[attr])
               else:
                   args.append(value)

            return tuple.__new__(cls, tuple(args))
        """ % (name, mandatory), 'from_dict')

        _replace = execute_template("""
        def _replace(self, **kwds):
            'Return a new %s object replacing specified fields with new values'

            result = self._make(map(kwds.pop, %r, self))
            if kwds:
                msg = 'Got unexpected field names: %%r' %% kwds.keys()
                raise ValueError(msg)
            return result
        """ % (name, attrs), '_replace')

        def __getnewargs__(self):                            # pragma: no cover
            return tuple(self)

        # Remove specification.
        if 'mandatory' in dct: del dct['mandatory']
        if 'optional'  in dct: del dct['optional']

        # Add methods to class definition.
        dct['__slots__'] = ()
        dct['_mandatory'] = mandatory
        dct['_optional'] = optional
        dct['__new__'] = __new__
        dct['_make'] = _make
        dct['__repr__'] = __repr__
        dct['to_dict'] = to_dict
        dct['from_dict'] = from_dict
        dct['_replace'] = _replace
        dct['__getnewargs__'] = __getnewargs__

        # Add broadcaster and listener.
        dct['broadcaster'] = property(lambda self: broadcaster)
        dct['listener'] = property(lambda self: listener)

        # Add properties (read-only access).
        for i, attr in enumerate(attrs):
            dct[attr] = property(operator.itemgetter(i))

        # Create object.
        obj = super(_ConnectionMeta, cls).__new__(cls, name, bases, dct)

        # For pickling to work, the __module__ variable needs to be set to the
        # frame where the named tuple is created.  Bypass this step in
        # enviroments where sys._getframe is not defined (Jython for example).
        if hasattr(sys, '_getframe'):
            obj.__module__ = sys._getframe(1).f_globals.get('__name__',
                                                            '__main__')

        return obj


[docs]class Connection(tuple): """Base class for MCL network interface connection objects. The :class:`~.abstract.Connection` object provides a base class for defining MCL network interface connection objects. Classes that inherit from :class:`~.abstract.Connection` **must** implement the attributes `mandatory`, `broadcaster` and `listener` where: - `mandatory` is a list of strings defining the names of mandatory message attributes that must be present when instances of the new :class:`.Message` objects are created. During instantiation the input list \*args is mapped to the attributes defined by `mandatory`. If `mandatory` is not present, a :exc:`~python:exceptions.TypeError` will be raised. - `broadcaster` is a reference to the :class:`.RawBroadcaster` object associated with the :class:`~.abstract.Connection` object. - `listener` is a reference to the :class:`.RawListener` object associated with the :class:`~.abstract.Connection` object. Classes that inherit from :class:`~.abstract.Connection` can **optionally** implement the attribute `optional` where: - `optional` is a dictionary of optional connection parameters and their defaults. Keywords represent attribute names and the corresponding value represents the default value. During instantiation of the new Connection object, \**kwargs is mapped to the attributes defined by `optional`. These attributes form the definition of the network interface connection and allow :class:`~.abstract.Connection` to manufacture a connection class adhering to the specified definition. None of the attribute names can be set to `mandatory`, `broadcaster`, `listener` or `optional`. :class:`~.abstract.Connection` objects behave like :obj:`python:collections.namedtuple` objects. That is, :class:`~.abstract.Connection` objects have fields accessible by attribute lookup as well as being indexable and iterable. However, since :class:`~.abstract.Connection` objects are tuple-like, the data they contain is immutable after instantiation. Example usage: .. testcode:: from mcl.network.abstract import Connection from mcl.network.abstract import RawListener from mcl.network.abstract import RawBroadcaster # Define new connection object WITH NO optional parameters (abstract # RawBroadcaster/Listener used for illustration). class ExampleConnection(Connection): mandatory = ('A',) broadcaster = RawBroadcaster listener = RawListener # Instantiate connection object. example = ExampleConnection('A') print example # Define new connection object WITH optional parameters. class ExampleConnection(Connection): mandatory = ('A',) optional = {'B': 1, 'C': 2, 'D': 3} broadcaster = RawBroadcaster listener = RawListener # Instantiate connection object. example = ExampleConnection('A', D=5) print example .. testoutput:: :hide: ExampleConnection(A='A') ExampleConnection(A='A', C=2, B=1, D=5) Raises: TypeError: If the mandatory or optional attributes are ill-specified. ValueError: If any of the mandatory or optional attribute names are ill-specified. """ __metaclass__ = _ConnectionMeta
[docs]class RawBroadcaster(object): """Abstract base class for sending data over a network interface. The :class:`.RawBroadcaster` is an abstract base class designed to provide a template for objects in the MCL ecosystem which broadcast data over a network interface. Broadcasters inheriting from this template are likely to integrate safely with the MCL system. Args: connection (:class:`~.abstract.Connection`): Connection object. topic (str): Default topic associated with the network interface. Attributes: connection (:class:`~.abstract.Connection`): Connection object. topic (str): Default topic associated with the network interface. is_open (bool): Returns :data:`True` if the network interface is open. Otherwise returns :data:`False`. counter (int): Number of broadcasts issued. Raises: TypeError: If any of the inputs are ill-specified. """ # Ensure abstract methods are redefined in child classes. __metaclass__ = abc.ABCMeta def __init__(self, connection, topic=None): """Document the __init__ method at the class level.""" # Ensure the connection object is properly specified. if not isinstance(connection, Connection): msg = "The argument 'connection' must be an instance of a " msg += "Connection() subclass." raise TypeError(msg) # Broadcasters can only store ONE default topic. Enforce this behaviour # by only accepting a string. if topic is not None and not isinstance(topic, basestring): raise TypeError("The argument 'topic' must be None or a string.") # Save connection parameters. self.__connection = connection self.__topic = topic @property def connection(self): return self.__connection @property def topic(self): return self.__topic @abc.abstractproperty def is_open(self): pass # pragma: no cover @abc.abstractmethod def _open(self): """Virtual: Open connection to network interface. Returns: :class:`bool`: Returns :data:`True` if the network interface was opened. If the network interface was already opened, the request is ignored and the method returns :data:`False`. """ pass # pragma: no cover @abc.abstractmethod
[docs] def publish(self, data, topic=None): """Virtual: Send data over network interface. Args: data (obj): Serialisable object to publish over the network interface. topic (str): Topic associated with published data. This option will temporarily override the topic specified during instantiation. """ # Ensure topic is a string.. if topic is not None and not isinstance(topic, basestring): raise TypeError("The argument 'topic' must be None or a string.")
@abc.abstractmethod
[docs] def close(self): """Virtual: Close connection to network interface. Returns: :class:`bool`: Returns :data:`True` if the network interface was closed. If the network interface was already closed, the request is ignored and the method returns :data:`False`. """ pass # pragma: no cover
[docs]class RawListener(mcl.event.event.Event): """Abstract base class for receiving data over a network interface. The :class:`.RawListener` is an abstract base class designed to provide a template for objects in the MCL ecosystem which listen for data over a network interface. Listeners inheriting from this template are likely to integrate safely with the MCL system. Network data is made available to subscribers by issuing callbacks, when data arrives, in the following format:: {'topic': str, 'payload': obj()} where: - **<topic>** is a string containing the topic associated with the received data. - **<payload>** is the received (serialisable) data. .. note:: :class:`.RawListener` implements the event-based programming paradigm by inheriting from :class:`.Event`. Data can be issued to callback functions by calling the RawListener.__trigger__ method. This method has been removed from the public API to prevent *users* from calling the method. In concrete implementations of the :class:`.RawListener, *developers* can call the '__trigger__' method in I/O loops when network data is available. Args: connection (:class:`~.abstract.Connection`): Connection object. topics (str or list): Topics associated with the network interface represented as either a string or list of strings. Attributes: connection (:class:`~.abstract.Connection`): Connection object. topics (str or list): Topics associated with the network interface. is_open (bool): Returns :data:`True` if the network interface is open. Otherwise returns :data:`False`. counter (int): Number of broadcasts received. Raises: TypeError: If any of the inputs are ill-specified. """ def __init__(self, connection, topics=None): """Document the __init__ method at the class level.""" # Ensure the connection object is properly specified. if not isinstance(connection, Connection): msg = "The argument 'connection' must be a Connection() subclass." raise TypeError(msg) # Broadcasters can only store ONE default topic. Enforce this behaviour # by only accepting a string. if topics is not None and not isinstance(topics, basestring) and not \ all(isinstance(item, basestring) for item in topics): msg = "The argument 'topics' must be None, a string or a list of " msg += "string." raise TypeError(msg) # Save connection parameters. self.__connection = connection self.__topics = topics # Initialise Event() object. super(RawListener, self).__init__() @property def connection(self): return self.__connection @property def topics(self): return self.__topics @abc.abstractproperty def is_open(self): pass # pragma: no cover @abc.abstractmethod def _open(self): """Virtual: Open connection to network interface. Returns: :class:`bool`: Returns :data:`True` if the network interface was opened. If the network interface was already opened, the request is ignored and the method returns :data:`False`. """ pass # pragma: no cover @abc.abstractmethod
[docs] def close(self): """Virtual: Close connection to network interface. Returns: :class:`bool`: Returns :data:`True` if the network interface was closed. If the network interface was already closed, the request is ignored and the method returns :data:`False`. """ pass # pragma: no cover