diff --git a/frappy/client/__init__.py b/frappy/client/__init__.py index 4b34c32..3366ea5 100644 --- a/frappy/client/__init__.py +++ b/frappy/client/__init__.py @@ -593,8 +593,7 @@ class SecopClient(ProxyClient): raise ConnectionError('connection closed before reply') action, _, data = entry[2] # pylint: disable=unpacking-non-sequence if action.startswith(ERRORPREFIX): - errcls = self.error_map(data[0]) - raise errcls(data[1]) + raise frappy.errors.make_secop_error(*data[0:2]) return entry[2] # reply def request(self, action, ident=None, data=None): @@ -647,15 +646,9 @@ class SecopClient(ProxyClient): # the following attributes may be/are intended to be overwritten by a subclass - ERROR_MAP = frappy.errors.EXCEPTIONS - DEFAULT_EXCEPTION = frappy.errors.SECoPError PREDEFINED_NAMES = set(frappy.params.PREDEFINED_ACCESSIBLES) activate = True - def error_map(self, exc): - """how to convert SECoP and unknown exceptions""" - return self.ERROR_MAP.get(exc, self.DEFAULT_EXCEPTION) - def internalize_name(self, name): """how to create internal names""" if name.startswith('_') and name[1:] not in self.PREDEFINED_NAMES: diff --git a/frappy/datatypes.py b/frappy/datatypes.py index 381b229..8162edc 100644 --- a/frappy/datatypes.py +++ b/frappy/datatypes.py @@ -29,7 +29,7 @@ import sys from base64 import b64decode, b64encode from frappy.errors import WrongTypeError, RangeError, \ - ConfigError, ProgrammingError, ProtocolError + ConfigError, ProgrammingError, ProtocolError, DiscouragedConversion from frappy.lib import clamp, generalConfig from frappy.lib.enum import Enum from frappy.parse import Parser @@ -45,11 +45,6 @@ UNLIMITED = 1 << 64 # internal limit for integers, is probably high enough for Parser = Parser() -class DiscouragedConversion(WrongTypeError): - """the discouraged conversion string - > float happened""" - log_message = True - - def shortrepr(value): """shortened repr for error messages diff --git a/frappy/errors.py b/frappy/errors.py index 97cc2f7..b726198 100644 --- a/frappy/errors.py +++ b/frappy/errors.py @@ -20,14 +20,25 @@ # Markus Zolliker # # ***************************************************************************** -"""Define (internal) SECoP Errors""" +"""Define (internal) SECoP Errors + +all error classes inherited from SECoPError should be placed in this module, +else they might not be registered and can therefore not be rebuilt on the client side +""" import re -from ast import literal_eval class SECoPError(RuntimeError): silent = False # silent = True indicates that the error is already logged + clsname2class = {} # needed to convert error reports back to classes + name = 'InternalError' + name2class = {} + + def __init_subclass__(cls): + cls.clsname2class[cls.__name__] = cls + if 'name' in cls.__dict__: + cls.name2class[cls.name] = cls def __init__(self, *args, **kwds): super().__init__() @@ -44,52 +55,67 @@ class SECoPError(RuntimeError): res.append(args) if kwds: res.append(kwds) - return '%s(%s)' % (self.name, ', '.join(res)) - - @property - def name(self): - return self.__class__.__name__[:-len('Error')] - - -class SECoPServerError(SECoPError): - name = 'InternalError' + return '%s(%s)' % (self.name or type(self).__name__, ', '.join(res)) class InternalError(SECoPError): + """uncatched error""" name = 'InternalError' class ProgrammingError(SECoPError): - name = 'InternalError' + """catchable programming error""" class ConfigError(SECoPError): - name = 'InternalError' + """invalid configuration""" class ProtocolError(SECoPError): + """A malformed request or on unspecified message was sent + + This includes non-understood actions and malformed specifiers. + Also if the message exceeds an implementation defined maximum size. + """ name = 'ProtocolError' class NoSuchModuleError(SECoPError): + """missing module + + The action can not be performed as the specified module is non-existent""" name = 'NoSuchModule' -# pylint: disable=redefined-builtin -class NotImplementedError(NotImplementedError, SECoPError): - pass +class NotImplementedSECoPError(NotImplementedError, SECoPError): + """not (yet) implemented + + A (not yet) implemented action or combination of action and specifier + was requested. This should not be used in productive setups, but is very + helpful during development.""" + name = 'NotImplemented' class NoSuchParameterError(SECoPError): - pass + """missing parameter + + The action can not be performed as the specified parameter is non-existent. + Also raised when trying to use a command name in a 'read' or 'change' message. + """ + name = 'NoSuchParameter' class NoSuchCommandError(SECoPError): - pass + """The specified command does not exist + + Also raised when trying to use a parameter name in a 'do' message. + """ + name = 'NoSuchCommand' class ReadOnlyError(SECoPError): - pass + """The requested write can not be performed on a readonly value""" + name = 'ReadOnly' class BadValueError(SECoPError): @@ -97,42 +123,106 @@ class BadValueError(SECoPError): class RangeError(ValueError, BadValueError): + """data out of range + + The requested parameter change or Command can not be performed as the + argument value is not in the allowed range specified by the datainfo + property. This also happens if an unspecified Enum variant is tried + to be used, the size of a Blob or String does not match the limits + given in the descriptive data, or if the number of elements in an + array does not match the limits given in the descriptive data.""" name = 'RangeError' +class BadJSONError(SECoPError): + """The data part of the message can not be parsed, i.e. the JSON-data is no valid JSON. + + not used in Frappy, but might appear on the client side from a foreign SEC Node + """ + # TODO: check whether this should not be removed from specs! + name = 'BadJSON' + + class WrongTypeError(TypeError, BadValueError): - pass + """Wrong data type + + The requested parameter change or Command can not be performed as the + argument has the wrong type. (i.e. a string where a number is expected.) + It may also be used if an incomplete struct is sent, but a complete + struct is expected.""" + name = 'WrongType' + + +class DiscouragedConversion(ProgrammingError): + """the discouraged conversion string - > float happened""" + log_message = True class CommandFailedError(SECoPError): - pass + name = 'CommandFailed' class CommandRunningError(SECoPError): - pass + """The command is already executing. + + request may be retried after the module is no longer BUSY + (retryable)""" + name = 'CommandRunning' class CommunicationFailedError(SECoPError): - pass + """Some communication (with hardware controlled by this SEC node) failed + (retryable)""" + name = 'CommunicationFailed' + + +class SilentCommunicationFailedError(CommunicationFailedError): + silent = True class IsBusyError(SECoPError): - pass + """The requested action can not be performed while the module is Busy + or the command still running""" + name = 'IsBusy' class IsErrorError(SECoPError): - pass + """The requested action can not be performed while the module is in error state""" + name = 'IsError' class DisabledError(SECoPError): - pass + """The requested action can not be performed while the module is disabled""" + name = 'disabled' + + +class ImpossibleError(SECoPError): + """The requested action can not be performed at the moment""" + name = 'Impossible' + + +class ReadFailedError(SECoPError): + """The requested parameter can not be read just now""" + name = 'ReadFailed' + + +class OutOfRangeError(SECoPError): + """The requested parameter can not be read just now""" + name = 'OutOfRange' class HardwareError(SECoPError): + """The connected hardware operates incorrect or may not operate at all + due to errors inside or in connected components.""" name = 'HardwareError' -FRAPPY_ERROR = re.compile(r'(.*)\(.*\)$') +class TimeoutSECoPError(TimeoutError, SECoPError): + """Some initiated action took longer than the maximum allowed time (retryable)""" + name = 'TimeoutError' + + +FRAPPY_ERROR = re.compile(r'(\w*): (.*)$') def make_secop_error(name, text): @@ -142,47 +232,26 @@ def make_secop_error(name, text): :param text: the second item of a SECoP error report :return: the built instance of SECoPError """ - try: - # try to interprete the error text as a repr() - # as it would be created by a Frappy server - cls, textarg = FRAPPY_ERROR.match(text).groups() - errcls = locals()[cls] - if errcls.name == name: - # convert repr() to - text = literal_eval(textarg) - except Exception: - # probably not a Frappy server, or running a different version - errcls = EXCEPTIONS.get(name, InternalError) - return errcls(text) + match = FRAPPY_ERROR.match(text) + if match: + clsname, errtext = match.groups() + errcls = SECoPError.clsname2class.get(clsname) + if errcls: + return errcls(errtext) + return SECoPError.name2class.get(name, InternalError)(text) -def secop_error(exception): - if isinstance(exception, SECoPError): - return exception - return InternalError(repr(exception)) +def secop_error(exc): + """turn into InternalError, if not already a SECoPError""" + if isinstance(exc, SECoPError): + if SECoPError.name2class.get(exc.name) != type(exc): + return type(exc)('%s: %s' % (type(exc).__name__, exc)) + return exc + return InternalError('%s: %s' % (type(exc).__name__, exc)) -EXCEPTIONS = {e().name: e for e in [ - NoSuchModuleError, - NoSuchParameterError, - NoSuchCommandError, - CommandFailedError, - CommandRunningError, - ReadOnlyError, - BadValueError, - RangeError, - WrongTypeError, - CommunicationFailedError, - HardwareError, - IsBusyError, - IsErrorError, - DisabledError, - ProtocolError, - NotImplementedError, - InternalError]} - # TODO: check if these are really needed: -EXCEPTIONS.update( +SECoPError.name2class.update( SyntaxError=ProtocolError, # internal short versions (candidates for spec) Protocol=ProtocolError, diff --git a/frappy/io.py b/frappy/io.py index 4903ea4..f932086 100644 --- a/frappy/io.py +++ b/frappy/io.py @@ -31,7 +31,8 @@ import threading from frappy.lib.asynconn import AsynConn, ConnectionClosed from frappy.datatypes import ArrayOf, BLOBType, BoolType, FloatRange, IntRange, \ StringType, TupleOf, ValueType -from frappy.errors import CommunicationFailedError, ConfigError, ProgrammingError +from frappy.errors import CommunicationFailedError, ConfigError, ProgrammingError, \ + SilentCommunicationFailedError as SilentError from frappy.modules import Attached, Command, \ Communicator, Module, Parameter, Property from frappy.lib import generalConfig @@ -41,10 +42,6 @@ generalConfig.set_default('legacy_hasiodev', False) HEX_CODE = re.compile(r'[0-9a-fA-F][0-9a-fA-F]$') -class SilentError(CommunicationFailedError): - silent = True - - class HasIO(Module): """Mixin for modules using a communicator""" io = Attached(mandatory=False) # either io or uri must be given diff --git a/frappy/protocol/dispatcher.py b/frappy/protocol/dispatcher.py index 9b479b6..78f18a5 100644 --- a/frappy/protocol/dispatcher.py +++ b/frappy/protocol/dispatcher.py @@ -43,7 +43,7 @@ from collections import OrderedDict from time import time as currenttime from frappy.errors import NoSuchCommandError, NoSuchModuleError, \ - NoSuchParameterError, ProtocolError, ReadOnlyError, SECoPServerError + NoSuchParameterError, ProtocolError, ReadOnlyError from frappy.params import Parameter from frappy.protocol.messages import COMMANDREPLY, DESCRIPTIONREPLY, \ DISABLEEVENTSREPLY, ENABLEEVENTSREPLY, ERRORPREFIX, EVENTREPLY, \ @@ -55,7 +55,7 @@ def make_update(modulename, pobj): if pobj.readerror: return (ERRORPREFIX + EVENTREPLY, '%s:%s' % (modulename, pobj.export), # error-report ! - [pobj.readerror.name, repr(pobj.readerror), {'t': pobj.timestamp}]) + [pobj.readerror.name, str(pobj.readerror), {'t': pobj.timestamp}]) return (EVENTREPLY, '%s:%s' % (modulename, pobj.export), [pobj.export_value(), {'t': pobj.timestamp}]) @@ -297,7 +297,7 @@ class Dispatcher: if handler: return handler(conn, specifier, data) - raise SECoPServerError('unhandled message: %s' % repr(msg)) + raise ProtocolError('unhandled message: %s' % repr(msg)) # now the (defined) handlers for the different requests def handle_help(self, conn, specifier, data): diff --git a/test/test_errors.py b/test/test_errors.py index 5c98d81..82a0e14 100644 --- a/test/test_errors.py +++ b/test/test_errors.py @@ -21,16 +21,28 @@ # ***************************************************************************** """test data types.""" -import frappy.errors -from frappy.errors import EXCEPTIONS, SECoPError +import pytest +from frappy.errors import RangeError, WrongTypeError, ProgrammingError, \ + ConfigError, InternalError, DiscouragedConversion, secop_error, make_secop_error -def test_errors(): - """check consistence of frappy.errors.EXCEPTIONS""" - for e in EXCEPTIONS.values(): - assert EXCEPTIONS[e().name] == e - # check that all defined secop errors are in EXCEPTIONS - for cls in frappy.errors.__dict__.values(): - if isinstance(cls, type) and issubclass(cls, SECoPError): - if cls != SECoPError: - assert cls().name in EXCEPTIONS +@pytest.mark.parametrize('exc, name, text, echk', [ + (RangeError('out of range'), 'RangeError', 'out of range', None), + (WrongTypeError('bad type'), 'WrongType', 'bad type', None), + (ProgrammingError('x'), 'InternalError', 'ProgrammingError: x', None), + (ConfigError('y'), 'InternalError', 'ConfigError: y', None), + (InternalError('z'), 'InternalError', 'z', None), + (DiscouragedConversion('w'), 'InternalError', 'DiscouragedConversion: w', None), + (ValueError('v'), 'InternalError', "ValueError: v", InternalError("ValueError: v")), + (None, 'InternalError', "UnknownError: v", InternalError("UnknownError: v")), +]) +def test_errors(exc, name, text, echk): + """check consistence of frappy.errors""" + if exc: + err = secop_error(exc) + assert err.name == name + assert str(err) == text + recheck = make_secop_error(name, text) + echk = echk or exc + assert type(recheck) == type(echk) + assert str(recheck) == str(echk)