Source code for controllab.wrapper

# Please use the
# http://google-styleguide.googlecode.com/svn/trunk/pyguide.html
# as documentation format for this class
# disable some pylint checks.
# pylint: disable=broad-except, too-many-arguments, too-few-public-methods
# pylint: disable=too-many-lines, too-many-public-methods
""" The wrapper submodule forms the core of the Python XML-RPC communication.
"""

import collections
import errno
import time

from .support import ControllabError, flatten
from .tool import ExeTool


try:
    import numpy as np
except ImportError:
    pass
try:
    import xmlrpc.client as xmlrpclib  # @UnusedImport
except ImportError:
    import xmlrpclib  # @UnresolvedImport @Reimport pylint: disable=F0401

ValueWithProperties = collections.namedtuple(
    'ValueWithProperties', [
        'name',
        'value',
        'unit',
        'quantity',
        'unitSymbol',
        'arithmetictype',
        'description'])

CodeTarget = collections.namedtuple(
    'codetarget', ['name', 'submodelselection'])


def dict2value_with_properties(values):
    """ TODO """
    if values is None:
        return None

    if values is False:
        return False

    value_properties = []

    for item in values:

        if len(item['values']) == 1:
            itemvalue = item['values'][0]
        else:
            itemvalue = item['values']

        # Iterate through the properties to find the info for our
        # namedtuple
        par_properties = item['properties']
        unit_symbol = ''
        unit = ''
        quantity = ''
        arithmetictype = 'real'
        description = ''
        for prop in par_properties:
            name = prop['key']
            value = prop['value']

            if name == 'unitSymbol':
                unit_symbol = value
            elif name == 'unit':
                unit = value
            elif name == 'quantity':
                quantity = value
            elif name == 'arithmetictype':
                arithmetictype = value
            elif name == 'description':
                description = value

        value_properties.append(
            ValueWithProperties(
                item['name'],
                itemvalue,
                unit,
                quantity,
                unit_symbol,
                arithmetictype,
                description))

    return value_properties


class ErrorhandlerEnum(object):
    """Contains the standard values for the error handler settings.

    * NONE prints errors in human readable format to *stdout* and continue.
    * UNEXPECTED prints non-XMLRPC errors and re-raise XMLRPC errors.
    * XMLRPCLIB prints XMLRPC related errors and re-raise other errors.
    * ALL will not print anything, but re-raise all errors.

    UNEXPECTED and ALL are useful for scripting as it allows the programmer
    to decide what to do with the errors.

    Example:
        >>> from xxsim import XXSimWrapper
        >>> xxsim = XXSimWrapper(XXSimWrapper.RAISERRORS.UNEXPECTED)
    """
    NONE = 0
    UNEXPECTED = 1
    XMLRPCLIB = 2
    ALL = 3

RAISEERRORS = ErrorhandlerEnum()


class WrapperError(ControllabError):

    """ A generic Wrapper error. """
    pass


class Wrapper(object):

    """ This is the generic XMLRPC Python Wrapper superclass.

    This class should be treated as abstract, and should not be instantiated.

    Attributes:
        errorlevel (RaiseErrors): The level of errors that should be raised.
        uri (str): The URI of the xmlrpc server to connect to.
    """

    # Override in subclasses
    DEFAULT_PORT = None

    def __init__(self, errorlevel=RAISEERRORS.NONE):
        """ Generic Wrapper constructor only to be called as super().__init__()
        """
        if self.__class__ is Wrapper:
            raise ControllabError(
                'Error: Init of abstract Wrapper class!\n')
        self.errorlevel = errorlevel
        self.tool = None
        self.server = None
        self.api = None

    def errorhandler(self, error):
        """ This function simplifies error handling.

        Given an error, depending on
        the self.errorlevel of the class, the error is either re-raised, or
        formatted to a more human readable format and printed while execution
        continues.

        You can set errorlevel to RAISEERRORS.NONE for human use, where no
        errors are raised, but the user will be informed in a sensible way.

        For scripting you can use RAISERRORS.ALL to ensure that errors are
        raised to the calling script, the script programmer can decide how the
        error should be handled in each case.

        Args:
            error (Exception): The error

        Example:
            >>> try:
            ...     1/0
            ... except ZeroDivisionError as error:
            ...     self.errorhandler(error)
        """
        msg = ''
        if isinstance(error, xmlrpclib.Error):
            if isinstance(error, xmlrpclib.Fault):
                msg = 'An error occurred:\n{}'.format(
                    error.faultString)
            elif isinstance(error, xmlrpclib.ResponseError):
                msg = 'An error occurred: {}\n'.format(
                    error.faultString)
            elif isinstance(error, xmlrpclib.ProtocolError):
                msg = ('A Protocol Error occurred:\n'
                       'URL: {}\n'
                       'HTTP(S) headers: {}\n'
                       'Code: {}\n'
                       'Message: {}\n').format(error.url,
                                               error.headers,
                                               error.errcode,
                                               error.errmsg)
            if self.errorlevel == RAISEERRORS.XMLRPCLIB:
                raise error
        elif isinstance(error, AttributeError) and not self.server:
            msg = 'An error occurred. '
            msg += 'Please verify that you are connected to {}:\n{}\n'.format(
                self._get_default_exetool(None).name, error)
            if self.errorlevel == RAISEERRORS.XMLRPCLIB:
                raise error
        else:
            msg = 'An error occurred:\n{}'.format(error)
            if self.errorlevel == RAISEERRORS.UNEXPECTED:
                raise error
        if self.errorlevel == RAISEERRORS.ALL:
            raise error
        print(msg)

    def is_connected(self, maxtries=10):
        """Internal function, do not call externally. This function is used to
        wait until the tool has started. It tries to call the tool's XML-RPC
        server once every second for a given number of tries and returns false
        in case of failure.

        This function requires self.server to be set.

        Args:
            maxtries (int, optional): Number of times to try the connection.
                Default is 10.


        Returns:
            bool: True on success, false on failure.

        Example:
            Use the default number of 10 tries.

            >>> is_connected()
            True

            A trivial case. Trying 0 or less times will always fail.

            >>> is_connected(0)
            False
        """
        trycount = 0
        while trycount < maxtries:
            trycount += 1
            try:
                self.server.getVersion()
                return True
            except EnvironmentError as error:
                # OSError is raised in Python 3, socket.error in Python 2
                # ECONNREFUSED is the expected errno if 20-sim is not started.
                if error.errno != errno.ECONNREFUSED:
                    raise error
                time.sleep(1)
        return False

    # pylint: disable=no-self-use, unused-argument
    def _get_default_exetool(self, version=None, **kwargs):
        """ This method has to be overriden by subclasses to correctly return
            the default ExeTool for that wrapper.

            Raises:
                WrapperError : Always
        """
        raise WrapperError('Abstract method called.\n'
                           'This method must be overriden by a subclass.\n')

    def _set_xrc_server(self, xrc):
        """ This method has to be overriden by subclasses to correctly set
            the self.server attribute.

            Raises:
                WrapperError : Always
        """
        raise WrapperError('Abstract method called.\n'
                           'This method must be overriden by a subclass.\n')

    def connect(self, uri, autostart=True, version=None):
        """Create a connection to a Tool using XML-RPC.
        Override with sensible tool specific connect in subclass.

        Args:
            uri (str): The URI to connect to.
            autostart (bool): Start program if no instance is running.
                Default: True
            version (string): Version of the program to connect to.
        """
        command_args = []
        # Divide uri in protocol and remainder.
        part = uri.partition('://')
        protocol = ''
        if part[1]:
            protocol = part[0]
        else:
            protocol = 'http'
            # Prepend default protocol to uri. Since the user forgot it.
            uri = protocol + '://' + uri
        # Add port command line argument if a port is specified
        try:
            # Extract port number from remainder.
            port = int(part[2].rpartition(':')[2])
            # Partition is useful because part[1] is empty if not found.
            command_args.append(
                '-{}-port={}'.format(protocol, port))
        except ValueError:
            # No port was specified in URI. Append class default port otherwise
            # it uses the Python XMLRPC client default port.
            uri += ':{}'.format(self.__class__.DEFAULT_PORT)
        # Try to connect to the default server
        self._set_xrc_server(xmlrpclib.ServerProxy(uri))
        try:
            toolinfo = self.server.getVersion()
            tool = ExeTool(
                toolinfo['name'],
                toolinfo['version'],
                command_args=command_args)
            # TODO: Add to 4C
            # self.api = Tool('API', toolinfo['interfaceVersion'])

            if version and tool.majorminor_versionstring() != version:
                # Otherwise subsequent calls can affect the running 20-sim.
                self.disconnect()
                self.errorhandler(
                    WrapperError((
                        'User asked for connection to {0} {1}'
                        ' but the server is running {2}.\n'
                        'Please close {2} on {3} '
                        'and retry.\n').format(tool.name,
                                               version,
                                               tool.fullname_string(),
                                               uri)))
                return False
        except EnvironmentError as error:
            # OSError is raised in Python 3, socket.error in Python 2
            # ECONNREFUSED is the expected errno if 20-sim is not started.
            if error.errno != errno.ECONNREFUSED:
                raise error
            elif autostart:
                try:
                    tool = self._get_default_exetool(
                        version,
                        command_args=command_args)
                    if not uri.index(
                            'localhost') and not uri.index('127.0.0.1'):
                        raise WrapperError(
                            'No connection to {0} could be made. Please verify'
                            ' that {1} can be reached on {0}.\n'
                            .format(uri, tool.name))
                    if not tool.start_instance() or not self.is_connected():
                        if version is None:
                            version = 'default version'
                            raise WrapperError(('Tried to start {0} {1}, but '
                                                'no connection could be made. '
                                                'Please start {0} {1} manually'
                                                ' and retry.\n')
                                               .format(tool.name,
                                                       version))
                except Exception as error:
                    # Otherwise subsequent calls can affect the running 20-sim.
                    self.disconnect()
                    self.errorhandler(error)
                    return False

            else:
                # Otherwise subsequent calls can affect the running 20-sim.
                self.disconnect()
                return False
        # It seems the connected tool is correct, so we save it.
        self.tool = tool
        return True

    def get_variables(self, names=None, kinds=None):
        """Returns a list of dicts with the specified variables

        Args:
            names (*str* or *list of str*): A single name of a variable
                or a list with variable names. Names must contain the full
                hierarchy e.g ``PID.Kp``. If omitted, this function will return
                all available variables from the active model
            kinds (*str* or *list of str*, optional): The kinds of
                variable requested. Choose one or more from:

                * parameter
                * variable
                * state
                * rate
                * initialValue
                * dependentRate
                * algebraic
                * algebraicin
                * algebraicout
                * constraint
                * constraintin
                * constraintout

                *Note*: If *no* kind is specified, *all* kinds are retrieved.

        Returns:
            list *or* bool: A list of dicts with the following fields:

                * ``name`` *str* The name of the retrieved variable
                * ``size`` The size of the variable
                * ``values`` *list* of *float* The values of the variable
                * ``properties`` The properties of the variable(s)

        Examples:
            To retrieve a list of parameters and state variables found in the
            submodels PID and Amplifier, you can use:

        >>> variables = get_variables(
                ['PID','Amplifier'],
                ['parameter','state'])
        """

        # Check input arguments
        if not names:
            names = []
        elif isinstance(names, str):
            names = [names]

        if not kinds:
            kinds = []
        elif isinstance(kinds, str):
            kinds = [kinds]

        filters = [{'key': 'kind', 'value': kind} for kind in kinds]
        try:
            # the actual XML-RPC call
            variables = self.server.model.getVariables(
                {'variables': names, 'filters': filters})
            return variables
        except Exception as error:
            self.errorhandler(error)

        return False

    def set_variables(self, names, values):
        """ Sets the specified variable(s) to a new value

        Args:
            names (list):  names of the variables to be set.
            values (list): the new values of the variables. Supported objects
                for the *values* list are:

                * Numbers
                * Strings (e.g. 'Hello World!')
                * Python lists of numbers for vectors (e.g. [1, 2, 3, 4])
                * Python lists of lists of numbers for matrices (e.g. [[1,2],[3,4]])
                * Any Numpy object that is a (sub)instance of numpy.ndarray
                  provided it has no more than 2 dimensions.

                If only a single variable is to be set, the name and the object
                can be passed as strings, there's no need to explicitly
                encapsulate them in lists.

        Returns:
          bool: True on success, False otherwise

        Example:
            To set the  'Controller_Continuous.kp' and 'Controller_Discrete.kp'
            to 3 and 4 respectively you can use:

            >>> result = set_variables(
            ['Controller_Continuous.kp', 'Controller_Discrete.kp'],
            [3.0, 4.0])

            To set a string *'Hello World!'* and a matrix
                *[[1.0, 3.5], [12.5,3.0]]*:

            >>> set_variables(
            ...    [ 'astring', 'somematrix' ],
            ...    [ 'Hello World!', [[1.0, 3.5], [12.5,3.0]] ])
        """
        # Check input arguments.
        if isinstance(names, str):
            names = [names]

        # Encapsulate in a list a single scalar value or a tuple
        if (isinstance(values, (str, int, float)) or
                isinstance(values, tuple) and len(names) == 1):
            values = [values]

        try:
            # Test whether the given arguments have the same length.
            if len(names) != len(values):
                raise WrapperError(
                    'Array size mismatch in given names and values')

            variables = []

            for name, value in zip(names, values):
                # Default size is one.
                size = [1]
                # Check if Numpy is loaded and we've received a Numpy object.
                if 'np' in globals() and np and isinstance(value, np.ndarray):
                    # All sorts of array structures.
                    size = value.shape
                    # 1. Ravel to one-dimension.
                    # 2. Convert to standard python list.
                    value = value.ravel().tolist()
                elif isinstance(value, (list, tuple)):
                    # Vector or matrix
                    size = [len(value)]
                    if isinstance(value[0], (list, tuple)):
                        # Matrix
                        size = [size[0], len(value[0])]
                        value = flatten(value)
                else:
                    # Encapsulate single number or string in list.
                    value = [value]
                variables.append({'name': name, 'size': size, 'value': value})

            # the actual XML-RPC call
            return self.server.model.setVariables(variables)
        except Exception as error:
            self.errorhandler(error)

    def set_settings(self, keys, values):
        """Sets model settings to the provided value.
        To set only a single setting, you can pass the key and value as strings.
        For the list of available settings, call :py:meth:`.query_settings()` once.

        Args:
            keys (list):   The list of settings.
            values (list): The values for the settings. Individual values will
                           be converted to a string.

        Returns:
            bool: True on success, False on failure.

        Examples:

            >>> set_settings(['testsetting1','testsetting2'],
            ...             ['value1','value2'])
            True

        The following are equivalent:

            >>> set_settings(['testsetting1'],['value1'])

            >>> set_settings('testsetting1','value1')
        """
        # check for single or list arguments
        if isinstance(keys, str):
            keys = [keys]
            values = [values]

        if len(keys) != len(values):
            self.errorhandler(
                ControllabError('Error: array sizes mismatch for '
                                'supplied keys and values'))
            return False

        # Convert arguments to settings list of dicts
        settings = [{'key': key, 'value': str(value)} for (
            key, value) in zip(keys, values)]

        try:
            # the actual XML-RPC call
            return self.server.setSettings({'settings': settings})
        except Exception as error:
            self.errorhandler(error)

    def disconnect(self):
        """Close the connection

        Returns:
            bool: True on success.

        Example:

        >>> disconnect()
        True
        """
        self.tool = None
        self.server = None
        return True

    def close(self):
        """Close the application you are connected to. """
        try:
            # the actual XML-RPC call.
            return self.server.close()
        except Exception as error:
            self.errorhandler(error)