diff --git a/secop/datatypes.py b/secop/datatypes.py index 03847ba..571c93c 100644 --- a/secop/datatypes.py +++ b/secop/datatypes.py @@ -102,7 +102,7 @@ class DataType(HasProperties): self.setProperty(k, v) self.checkProperties() except Exception as e: - raise ProgrammingError(str(e)) + raise ProgrammingError(str(e)) from None def get_info(self, **kwds): """prepare dict for export or repr @@ -194,7 +194,7 @@ class FloatRange(DataType): try: value = float(value) except Exception: - raise BadValueError('Can not convert %r to float' % value) + raise BadValueError('Can not convert %r to float' % value) from None # map +/-infty to +/-max possible number value = clamp(-sys.float_info.max, value, sys.float_info.max) @@ -268,7 +268,7 @@ class IntRange(DataType): fvalue = float(value) value = int(value) except Exception: - raise BadValueError('Can not convert %r to int' % value) + raise BadValueError('Can not convert %r to int' % value) from None if not self.min <= value <= self.max or round(fvalue) != fvalue: raise BadValueError('%r should be an int between %d and %d' % (value, self.min, self.max)) @@ -371,7 +371,7 @@ class ScaledInteger(DataType): try: value = float(value) except Exception: - raise BadValueError('Can not convert %r to float' % value) + raise BadValueError('Can not convert %r to float' % value) from None prec = max(self.scale, abs(value * self.relative_resolution), self.absolute_resolution) if self.min - prec <= value <= self.max + prec: @@ -454,7 +454,7 @@ class EnumType(DataType): try: return self._enum[value] except (KeyError, TypeError): # TypeError will be raised when value is not hashable - raise BadValueError('%r is not a member of enum %r' % (value, self._enum)) + raise BadValueError('%r is not a member of enum %r' % (value, self._enum)) from None def from_string(self, text): return self(text) @@ -533,7 +533,7 @@ class BLOBType(DataType): if self.minbytes < other.minbytes or self.maxbytes > other.maxbytes: raise BadValueError('incompatible datatypes') except AttributeError: - raise BadValueError('incompatible datatypes') + raise BadValueError('incompatible datatypes') from None class StringType(DataType): @@ -572,7 +572,7 @@ class StringType(DataType): try: value.encode('ascii') except UnicodeEncodeError: - raise BadValueError('%r contains non-ascii character!' % value) + raise BadValueError('%r contains non-ascii character!' % value) from None size = len(value) if size < self.minchars: raise BadValueError( @@ -606,7 +606,7 @@ class StringType(DataType): self.isUTF8 > other.isUTF8: raise BadValueError('incompatible datatypes') except AttributeError: - raise BadValueError('incompatible datatypes') + raise BadValueError('incompatible datatypes') from None # TextType is a special StringType intended for longer texts (i.e. embedding \n), @@ -618,7 +618,7 @@ class TextType(StringType): def __init__(self, maxchars=None): if maxchars is None: maxchars = UNLIMITED - super(TextType, self).__init__(0, maxchars) + super().__init__(0, maxchars) def __repr__(self): if self.maxchars == UNLIMITED: @@ -771,7 +771,7 @@ class ArrayOf(DataType): raise BadValueError('incompatible datatypes') self.members.compatible(other.members) except AttributeError: - raise BadValueError('incompatible datatypes') + raise BadValueError('incompatible datatypes') from None class TupleOf(DataType): @@ -811,7 +811,7 @@ class TupleOf(DataType): return tuple(sub(elem) for sub, elem in zip(self.members, value)) except Exception as exc: - raise BadValueError('Can not validate:', str(exc)) + raise BadValueError('Can not validate:', str(exc)) from None def export_value(self, value): """returns a python object fit for serialisation""" @@ -894,7 +894,7 @@ class StructOf(DataType): return ImmutableDict((str(k), self.members[k](v)) for k, v in list(value.items())) except Exception as exc: - raise BadValueError('Can not validate %s: %s' % (repr(value), str(exc))) + raise BadValueError('Can not validate %s: %s' % (repr(value), str(exc))) from None def export_value(self, value): """returns a python object fit for serialisation""" @@ -924,7 +924,7 @@ class StructOf(DataType): if mandatory: raise BadValueError('incompatible datatypes') except (AttributeError, TypeError, KeyError): - raise BadValueError('incompatible datatypes') + raise BadValueError('incompatible datatypes') from None class CommandType(DataType): @@ -958,7 +958,7 @@ class CommandType(DataType): argstr = repr(self.argument) if self.argument else '' if self.result is None: return 'CommandType(%s)' % argstr - return 'CommandType(%s)->%s' % (argstr, repr(self.result)) + return 'CommandType(%s, %s)' % (argstr, repr(self.result)) def __call__(self, value): """return the validated argument value or raise""" @@ -987,7 +987,7 @@ class CommandType(DataType): if self.result != other.result: # not both are None other.result.compatible(self.result) except AttributeError: - raise BadValueError('incompatible datatypes') + raise BadValueError('incompatible datatypes') from None # internally used datatypes (i.e. only for programming the SEC-node) @@ -999,7 +999,10 @@ class DataTypeType(DataType): returns the value or raises an appropriate exception""" if isinstance(value, DataType): return value - raise ProgrammingError('%r should be a DataType!' % value) + try: + return get_datatype(value) + except Exception as e: + raise ProgrammingError(e) from None def export_value(self, value): """if needed, reformat value for transport""" @@ -1034,6 +1037,13 @@ class ValueType(DataType): """ raise NotImplementedError + def setProperty(self, key, value): + """silently ignored + + as ValueType is used for the datatype default, this makes code + shorter for cases, where the datatype may not yet be defined + """ + class NoneOr(DataType): """validates a None or smth. else""" @@ -1080,7 +1090,7 @@ UInt64 = IntRange(0, (1 << 64) - 1) # Goodie: Convenience Datatypes for Programming class LimitsType(TupleOf): def __init__(self, members): - TupleOf.__init__(self, members, members) + super().__init__(members, members) def __call__(self, value): limits = TupleOf.__call__(self, value) @@ -1092,7 +1102,7 @@ class LimitsType(TupleOf): class StatusType(TupleOf): # shorten initialisation and allow access to status enumMembers from status values def __init__(self, enum): - TupleOf.__init__(self, EnumType(enum), StringType()) + super().__init__(EnumType(enum), StringType()) self._enum = enum def __getattr__(self, key): @@ -1164,9 +1174,9 @@ def get_datatype(json, pname=''): kwargs = json.copy() base = kwargs.pop('type') except (TypeError, KeyError, AttributeError): - raise BadValueError('a data descriptor must be a dict containing a "type" key, not %r' % json) + raise BadValueError('a data descriptor must be a dict containing a "type" key, not %r' % json) from None try: return DATATYPES[base](pname=pname, **kwargs) except Exception as e: - raise BadValueError('invalid data descriptor: %r (%s)' % (json, str(e))) + raise BadValueError('invalid data descriptor: %r (%s)' % (json, str(e))) from None diff --git a/secop/modules.py b/secop/modules.py index 1883e76..d63cb93 100644 --- a/secop/modules.py +++ b/secop/modules.py @@ -25,9 +25,10 @@ import sys import time +from collections import OrderedDict from secop.datatypes import ArrayOf, BoolType, EnumType, FloatRange, \ - IntRange, StatusType, StringType, TextType, TupleOf, get_datatype + IntRange, StatusType, StringType, TextType, TupleOf from secop.errors import BadValueError, ConfigError, InternalError, \ ProgrammingError, SECoPError, SilentError, secop_error from secop.lib import formatException, mkthread @@ -40,7 +41,7 @@ Done = object() #: a special return value for a read/write function indicating class HasAccessibles(HasProperties): - """base class of module + """base class of Module joining the class's properties, parameters and commands dicts with those of base classes. @@ -52,40 +53,43 @@ class HasAccessibles(HasProperties): super().__init_subclass__() # merge accessibles from all sub-classes, treat overrides # for now, allow to use also the old syntax (parameters/commands dict) - accessibles = {} - for base in reversed(cls.__bases__): - accessibles.update(getattr(base, 'accessibles', {})) - newaccessibles = {k: v for k, v in cls.__dict__.items() if isinstance(v, Accessible)} - for aname, aobj in list(accessibles.items()): - value = getattr(cls, aname, None) - if not isinstance(value, Accessible): # else override is already done in __set_name__ - if value is None: - accessibles.pop(aname) - else: - # this is either a method overwriting a command - # or a value overwriting a property value or parameter default - anew = aobj.override(value) - newaccessibles[aname] = anew - setattr(cls, aname, anew) - anew.__set_name__(cls, aname) - - ordered = {} - for aname in cls.__dict__.get('paramOrder', ()): + accessibles = OrderedDict() # dict of accessibles + merged_properties = {} # dict of dict of merged properties + new_names = [] # list of names of new accessibles + for base in reversed(cls.__mro__): + for key, value in base.__dict__.items(): + if isinstance(value, Accessible): + value.updateProperties(merged_properties.setdefault(key, {})) + if base == cls and key not in accessibles: + new_names.append(key) + accessibles[key] = value + elif key in accessibles: + # either a bare value overriding a parameter + # or a method overriding a command + aobj = aobj.copy() + aobj.override(value) + accessibles[key] = aobj + for aname, aobj in accessibles.items(): + if aobj != getattr(cls, aname, None): + aobj = aobj.copy() + setattr(cls, aname, aobj) + aobj.merge(merged_properties[aname]) + accessibles[aname] = aobj + # rebuild order: (1) inherited items, (2) items from paramOrder, (3) new accessibles + # move (2) to the end + for aname in list(cls.__dict__.get('paramOrder', ())): if aname in accessibles: - ordered[aname] = accessibles.pop(aname) - elif aname in newaccessibles: - ordered[aname] = newaccessibles.pop(aname) - # ignore unknown names - # starting from old accessibles not mentioned, append items from 'order' - accessibles.update(ordered) - # then new accessibles not mentioned - accessibles.update(newaccessibles) + accessibles.move_to_end(aname) + # ignore unknown names + # move (3) to the end + for aname in new_names: + accessibles.move_to_end(aname) + # note: for python < 3.6 the order of inherited items is not ensured between + # declarations within the same class cls.accessibles = accessibles # Correct naming of EnumTypes - for k, v in accessibles.items(): - if isinstance(v, Parameter) and isinstance(v.datatype, EnumType): - v.datatype.set_name(k) + # moved to Parameter.__set_name__ # check validity of Parameter entries for pname, pobj in accessibles.items(): @@ -318,8 +322,9 @@ class Module(HasAccessibles): paramobj = self.accessibles.get(paramname, None) # paramobj might also be a command (not sure if this is needed) if paramobj: - if propname == 'datatype': - propvalue = get_datatype(propvalue, k) + # no longer needed, this conversion is done by DataTypeType.__call__: + # if propname == 'datatype': + # propvalue = get_datatype(propvalue, k) try: paramobj.setProperty(propname, propvalue) except KeyError: @@ -347,6 +352,10 @@ class Module(HasAccessibles): self.valueCallbacks[pname] = [] self.errorCallbacks[pname] = [] + if not pobj.hasDatatype(): + errors.append('%s needs a datatype' % pname) + continue + if pname in cfgdict: if not pobj.readonly and pobj.initwrite is not False: # parameters given in cfgdict have to call write_ @@ -393,11 +402,15 @@ class Module(HasAccessibles): cfgdict.pop(k) except (ValueError, TypeError) as e: # self.log.exception(formatExtendedStack()) - errors.append('module %s, parameter %s: %s' % (self.name, k, e)) + errors.append('parameter %s: %s' % (k, e)) + + # ensure consistency + for aobj in self.accessibles.values(): + aobj.finish() # Modify units AFTER applying the cfgdict - for k, v in self.parameters.items(): - dt = v.datatype + for pname, pobj in self.parameters.items(): + dt = pobj.datatype if '$' in dt.unit: dt.setProperty('unit', dt.unit.replace('$', self.parameters['value'].datatype.unit)) @@ -410,7 +423,7 @@ class Module(HasAccessibles): for pname, p in self.parameters.items(): try: p.checkProperties() - except ConfigError: + except ConfigError as e: errors.append('%s: %s' % (pname, e)) if errors: raise ConfigError(errors) @@ -426,7 +439,7 @@ class Module(HasAccessibles): """announce a changed value or readerror""" pobj = self.parameters[pname] timestamp = timestamp or time.time() - changed = pobj.value != value or timestamp > (pobj.timestamp or 0) + 1 + changed = pobj.value != value if value is not None: pobj.value = value # store the value even in case of error if err: @@ -439,8 +452,10 @@ class Module(HasAccessibles): pobj.value = pobj.datatype(value) except Exception as e: err = secop_error(e) - if not changed: - return # experimental: do not update unchanged values within 1 sec + if not changed and timestamp < ((pobj.timestamp or 0) + + self.DISPATCHER.OMIT_UNCHANGED_WITHIN): + # no change within short time -> omit + return pobj.timestamp = timestamp pobj.readerror = err if pobj.export: diff --git a/secop/params.py b/secop/params.py index 6fe9274..72d0455 100644 --- a/secop/params.py +++ b/secop/params.py @@ -35,9 +35,17 @@ UNSET = object() # an argument not given, not even None class Accessible(HasProperties): - """base class for Parameter and Command""" + """base class for Parameter and Command - kwds = None # is a dict if it might be used as Override + Inheritance mechanism: + + param.propertyValues contains the properties, which will be used when the + owner class will be instantiated + + param.ownProperties contains the properties to be used for inheritance + """ + + ownProperties = None def init(self, kwds): # do not use self.propertyValues.update here, as no invalid values should be @@ -45,43 +53,47 @@ class Accessible(HasProperties): for k, v in kwds.items(): self.setProperty(k, v) - def inherit(self, cls, owner): - for base in owner.__bases__: - if hasattr(base, self.name): - aobj = getattr(base, 'accessibles', {}).get(self.name) - if aobj: - if not isinstance(aobj, cls): - raise ProgrammingError('%s %s.%s can not inherit from a %s' % - (cls.__name__, owner.__name__, self.name, aobj.__class__.__name__)) - # inherit from aobj - for pname, value in aobj.propertyValues.items(): - if pname not in self.propertyValues: - self.propertyValues[pname] = value - break - def as_dict(self): return self.propertyValues - def override(self, value=UNSET, **kwds): - """return a copy, overridden by a bare attribute - - and/or some properties""" + def override(self, value): + """override with a bare value""" raise NotImplementedError - def copy(self): - """return a (deep) copy of ourselfs""" + def copy(self, **kwds): + """return a (deep) copy of ourselfs + + :param kwds: override given properties + """ + raise NotImplementedError + + def updateProperties(self, merged_properties): + """update merged_properties with our own properties""" + raise NotImplementedError + + def merge(self, merged_properties): + """merge with inherited properties + + :param merged_properties: dict of properties to be updated + note: merged_properties may be modified + """ + raise NotImplementedError + + def finish(self): + """ensure consistency""" raise NotImplementedError def for_export(self): """prepare for serialisation""" raise NotImplementedError + def hasDatatype(self): + return 'datatype' in self.propertyValues + def __repr__(self): props = [] - for k, prop in sorted(self.propertyDict.items()): - v = self.propertyValues.get(k, prop.default) - if v != prop.default: - props.append('%s=%r' % (k, v)) + for k, v in sorted(self.propertyValues.items()): + props.append('%s=%r' % (k, v)) return '%s(%s)' % (self.__class__.__name__, ', '.join(props)) @@ -100,7 +112,7 @@ class Parameter(Accessible): extname='description', mandatory=True, export='always') datatype = Property( 'datatype of the Parameter (SECoP datainfo)', DataTypeType(), - extname='datainfo', mandatory=True, export='always') + extname='datainfo', mandatory=True, export='always', default=ValueType()) readonly = Property( 'not changeable via SECoP (default True)', BoolType(), extname='readonly', default=True, export='always') @@ -160,9 +172,13 @@ class Parameter(Accessible): timestamp = 0 readerror = None - def __init__(self, description=None, datatype=None, inherit=True, *, unit=None, constant=None, **kwds): + def __init__(self, description=None, datatype=None, inherit=True, **kwds): super().__init__() - if datatype is not None: + if datatype is None: + # collect datatype properties. these are not applied, as we have no datatype + self.ownProperties = {k: kwds.pop(k) for k in list(kwds) if k not in self.propertyDict} + else: + self.ownProperties = {} if not isinstance(datatype, DataType): if isinstance(datatype, type) and issubclass(datatype, DataType): # goodie: make an instance from a class (forgotten ()???) @@ -174,15 +190,15 @@ class Parameter(Accessible): if 'default' in kwds: self.default = datatype(kwds['default']) - self.init(kwds) # datatype must be defined before we can treat dataset properties like fmtstr or unit - if description is not None: - self.description = inspect.cleandoc(description) + kwds['description'] = inspect.cleandoc(description) - # save for __set_name__ - self._inherit = inherit - self._unit = unit # for legacy code only - self._constant = constant + self.init(kwds) + + if inherit: + self.ownProperties.update(self.propertyValues) + else: + self.ownProperties = {k: getattr(self, k) for k in self.propertyDict} def __get__(self, instance, owner): # not used yet @@ -195,57 +211,74 @@ class Parameter(Accessible): def __set_name__(self, owner, name): self.name = name + if isinstance(self.datatype, EnumType): + self.datatype.set_name(name) - if self._inherit: - self.inherit(Parameter, owner) + if self.export is True: + predefined_cls = PREDEFINED_ACCESSIBLES.get(self.name, None) + if predefined_cls is Parameter: + self.export = self.name + elif predefined_cls is None: + self.export = '_' + self.name + else: + raise ProgrammingError('can not use %r as name of a Parameter' % self.name) - # check for completeness - missing_properties = [pname for pname in ('description', 'datatype') if pname not in self.propertyValues] - if missing_properties: - raise ProgrammingError('Parameter %s.%s needs a %s' % - (owner.__name__, name, ' and a '.join(missing_properties))) - if self._unit is not None: - self.datatype.setProperty('unit', self._unit) + def copy(self, **kwds): + """return a (deep) copy of ourselfs - if self._constant is not None: - constant = self.datatype(self._constant) + :param kwds: override given properties + """ + res = type(self)() + res.name = self.name + res.init(self.propertyValues) + res.init(kwds) + if 'datatype' in self.propertyValues: + res.datatype = res.datatype.copy() + return res + + def updateProperties(self, merged_properties): + """update merged_properties with our own properties""" + datatype = self.ownProperties.get('datatype') + if datatype is not None: + # clear datatype properties, as they are overriden by datatype + for key in list(merged_properties): + if key not in self.propertyDict: + merged_properties.pop(key) + merged_properties.update(self.ownProperties) + + def override(self, value): + """override default""" + self.default = self.datatype(value) + + def merge(self, merged_properties): + """merge with inherited properties + + :param merged_properties: dict of properties to be updated + note: merged_properties may be modified + """ + datatype = merged_properties.pop('datatype', None) + if datatype is not None: + self.datatype = datatype.copy() + self.init(merged_properties) + self.finish() + + def finish(self): + """ensure consistency""" + + if self.constant is not None: + constant = self.datatype(self.constant) # The value of the `constant` property should be the # serialised version of the constant, or unset self.constant = self.datatype.export_value(constant) self.readonly = True - if 'default' in self.propertyValues: # fixes in case datatype has changed try: - self.datatype(self.default) + self.default = self.datatype(self.default) except BadValueError: # clear default, if it does not match datatype self.propertyValues.pop('default') - if self.export is True: - predefined_cls = PREDEFINED_ACCESSIBLES.get(name, None) - if predefined_cls is Parameter: - self.export = name - elif predefined_cls is None: - self.export = '_' + name - else: - raise ProgrammingError('can not use %r as name of a Parameter' % name) - - def copy(self): - # deep copy, as datatype might be altered from config - res = type(self)() - res.name = self.name - res.init(self.propertyValues) - res.datatype = res.datatype.copy() - return res - - def override(self, value=UNSET, **kwds): - res = self.copy() - res.init(kwds) - if value is not UNSET: - res.value = res.datatype(value) - return res - def export_value(self): return self.datatype.export_value(self.value) @@ -255,15 +288,23 @@ class Parameter(Accessible): def getProperties(self): """get also properties of datatype""" super_prop = super().getProperties().copy() - super_prop.update(self.datatype.getProperties()) + if self.datatype: + super_prop.update(self.datatype.getProperties()) return super_prop def setProperty(self, key, value): """set also properties of datatype""" - if key in self.propertyDict: - super().setProperty(key, value) - else: - self.datatype.setProperty(key, value) + try: + if key in self.propertyDict: + super().setProperty(key, value) + else: + try: + self.datatype.setProperty(key, value) + except KeyError: + raise ProgrammingError('cannot set %s on parameter with datatype %s' + % (key, type(self.datatype).__name__)) from None + except ValueError as e: + raise ProgrammingError('property %s: %s' % (key, str(e))) from None def checkProperties(self): super().checkProperties() @@ -336,10 +377,9 @@ class Command(Accessible): if self.func is None: raise ProgrammingError('Command %s.%s must be used as a method decorator' % (owner.__name__, name)) - if self._inherit: - self.inherit(Command, owner) self.datatype = CommandType(self.argument, self.result) + self.ownProperties = self.propertyValues.copy() if self.export is True: predefined_cls = PREDEFINED_ACCESSIBLES.get(name, None) if predefined_cls is Command: @@ -347,39 +387,77 @@ class Command(Accessible): elif predefined_cls is None: self.export = '_' + name else: - raise ProgrammingError('can not use %r as name of a Command' % name) + raise ProgrammingError('can not use %r as name of a Command' % name) from None + if not self._inherit: + for key, pobj in self.properties.items(): + if key not in self.propertyValues: + self.propertyValues[key] = pobj.default def __get__(self, obj, owner=None): if obj is None: return self if not self.func: - raise ProgrammingError('Command %s not properly configured' % self.name) + raise ProgrammingError('Command %s not properly configured' % self.name) from None return self.func.__get__(obj, owner) def __call__(self, func): + """called when used as decorator""" if 'description' not in self.propertyValues and func.__doc__: self.description = inspect.cleandoc(func.__doc__) self.func = func return self - def copy(self): + def copy(self, **kwds): + """return a (deep) copy of ourselfs + + :param kwds: override given properties + """ res = type(self)() res.name = self.name res.func = self.func res.init(self.propertyValues) + res.init(kwds) if res.argument: res.argument = res.argument.copy() if res.result: res.result = res.result.copy() - res.datatype = CommandType(res.argument, res.result) + self.finish() return res - def override(self, value=UNSET, **kwds): - res = self.copy() - res.init(kwds) - if value is not UNSET: - res.func = value - return res + def updateProperties(self, merged_properties): + """update merged_properties with our own properties""" + merged_properties.update(self.ownProperties) + + def override(self, value): + """override method + + this is needed when the @Command is missing on a method overriding a command""" + if not callable(value): + raise ProgrammingError('%s = %r is overriding a Command' % (self.name, value)) + self.func = value + + def merge(self, merged_properties): + """merge with inherited properties + + :param merged_properties: dict of properties to be updated + """ + self.init(merged_properties) + self.finish() + + def finish(self): + """ensure consistency""" + self.datatype = CommandType(self.argument, self.result) + + def setProperty(self, key, value): + """special treatment of datatype""" + try: + if key == 'datatype': + command = DataTypeType()(value) + super().setProperty('argument', command.argument) + super().setProperty('result', command.result) + super().setProperty(key, value) + except ValueError as e: + raise ProgrammingError('property %s: %s' % (key, str(e))) from None def do(self, module_obj, argument): """perform function call diff --git a/secop/protocol/dispatcher.py b/secop/protocol/dispatcher.py index 2485004..0273865 100644 --- a/secop/protocol/dispatcher.py +++ b/secop/protocol/dispatcher.py @@ -61,6 +61,8 @@ def make_update(modulename, pobj): class Dispatcher: + OMIT_UNCHANGED_WITHIN = 1 # do not send unchanged updates within 1 sec + def __init__(self, name, logger, options, srv): # to avoid errors, we want to eat all options here self.equipment_id = options.pop('id', name) diff --git a/test/test_iohandler.py b/test/test_iohandler.py index 03dd95b..21012ce 100644 --- a/test/test_iohandler.py +++ b/test/test_iohandler.py @@ -71,6 +71,8 @@ class Data: class DispatcherStub: + OMIT_UNCHANGED_WITHIN = 0 + def __init__(self, updates): self.updates = updates @@ -106,7 +108,6 @@ def test_IOHandler(): group1 = Hdl('group1', 'SIMPLE?', '%g') group2 = Hdl('group2', 'CMD?%(channel)d', '%g,%s,%d') - class Module1(Module): channel = Property('the channel', IntRange(), default=3) loop = Property('the loop', IntRange(), default=2) diff --git a/test/test_modules.py b/test/test_modules.py index b082e0d..cc6582a 100644 --- a/test/test_modules.py +++ b/test/test_modules.py @@ -26,14 +26,16 @@ import threading import pytest -from secop.datatypes import BoolType, FloatRange, StringType -from secop.errors import ProgrammingError -from secop.modules import Communicator, Drivable, Module +from secop.datatypes import BoolType, FloatRange, StringType, IntRange, CommandType +from secop.errors import ProgrammingError, ConfigError +from secop.modules import Communicator, Drivable, Readable, Module from secop.params import Command, Parameter from secop.poller import BasicPoller class DispatcherStub: + OMIT_UNCHANGED_WITHIN = 0 + def __init__(self, updates): self.updates = updates @@ -51,6 +53,9 @@ class LoggerStub: info = warning = exception = debug +logger = LoggerStub() + + class ServerStub: def __init__(self, updates): self.dispatcher = DispatcherStub(updates) @@ -125,7 +130,7 @@ def test_ModuleMagic(): value = Parameter(datatype=FloatRange(unit='deg')) a1 = Parameter(datatype=FloatRange(unit='$/s'), readonly=False) b2 = Parameter('', datatype=BoolType(), default=True, - poll=True, readonly=False, initwrite=True) + poll=True, readonly=False, initwrite=True) def write_a1(self, value): self._a1_written = value @@ -142,7 +147,6 @@ def test_ModuleMagic(): sortcheck2 = ['status', 'pollinterval', 'target', 'stop', 'a1', 'a2', 'cmd2', 'param1', 'param2', 'cmd', 'value', 'b2'] - logger = LoggerStub() updates = {} srv = ServerStub(updates) @@ -228,3 +232,103 @@ def test_ModuleMagic(): o.earlyInit() for o in objects: o.initModule() + + +def test_param_inheritance(): + srv = ServerStub({}) + + class Base(Module): + param = Parameter() + + class MissingDatatype(Base): + param = Parameter('param') + + class MissingDescription(Base): + param = Parameter(datatype=FloatRange(), default=0) + + # missing datatype and/or description of a parameter has to be detected + # at instantation and only then + with pytest.raises(ConfigError) as e_info: + MissingDatatype('o', logger, {'description': ''}, srv) + assert 'datatype' in repr(e_info.value) + + with pytest.raises(ConfigError) as e_info: + MissingDescription('o', logger, {'description': ''}, srv) + assert 'description' in repr(e_info.value) + + with pytest.raises(ConfigError) as e_info: + Base('o', logger, {'description': ''}, srv) + + +def test_mixin(): + # srv = ServerStub({}) + + class Mixin: # no need to inherit from Module or HasAccessible + value = Parameter(unit='K') # missing datatype and description acceptable in mixins + param1 = Parameter('no datatype yet', fmtstr='%.5f') + param2 = Parameter('no datatype yet', default=1) + + class MixedReadable(Mixin, Readable): + pass + + class MixedDrivable(MixedReadable, Drivable): + value = Parameter(unit='Ohm', fmtstr='%.3f') + param1 = Parameter(datatype=FloatRange()) + + with pytest.raises(ProgrammingError): + class MixedModule(Mixin): + param1 = Parameter('', FloatRange(), fmtstr=0) # fmtstr must be a string + + assert repr(MixedDrivable.status.datatype) == repr(Drivable.status.datatype) + assert repr(MixedReadable.status.datatype) == repr(Readable.status.datatype) + assert MixedReadable.value.datatype.unit == 'K' + assert MixedDrivable.value.datatype.unit == 'Ohm' + assert MixedDrivable.value.datatype.fmtstr == '%.3f' + # when datatype is overridden, fmtstr falls back to default: + assert MixedDrivable.param1.datatype.fmtstr == '%g' + + srv = ServerStub({}) + + MixedDrivable('o', logger, { + 'description': '', + 'param1.description': 'param 1', + 'param1': 0, + 'param2.datatype': {"type": "double"}, + }, srv) + + with pytest.raises(ConfigError): + MixedReadable('o', logger, { + 'description': '', + 'param1.description': 'param 1', + 'param1': 0, + 'param2.datatype': {"type": "double"}, + }, srv) + + +def test_command_config(): + class Mod(Module): + @Command(IntRange(0, 1), result=IntRange(0, 1)) + def convert(self, value): + return value + + srv = ServerStub({}) + mod = Mod('o', logger, { + 'description': '', + 'convert.argument': {'type': 'bool'}, + }, srv) + assert mod.commands['convert'].datatype.export_datatype() == { + 'type': 'command', + 'argument': {'type': 'bool'}, + 'result': {'type': 'int', 'min': 0, 'max': 1}, + } + + mod = Mod('o', logger, { + 'description': '', + 'convert.datatype': {'type': 'command', 'argument': {'type': 'bool'}, 'result': {'type': 'bool'}}, + }, srv) + assert mod.commands['convert'].datatype.export_datatype() == { + 'type': 'command', + 'argument': {'type': 'bool'}, + 'result': {'type': 'bool'}, + } + diff --git a/test/test_params.py b/test/test_params.py index 0976f8a..bf08cd7 100644 --- a/test/test_params.py +++ b/test/test_params.py @@ -88,15 +88,18 @@ def test_Override(): p1 = Parameter(default=True) p2 = Parameter() # override without change - assert Mod.p1 != Base.p1 - assert Mod.p2 != Base.p2 - assert Mod.p3 == Base.p3 - - assert id(Mod.p2) != id(Base.p2) # must be a new object - assert repr(Mod.p2) == repr(Base.p2) # but must be a clone + assert id(Mod.p1) != id(Base.p1) + assert id(Mod.p2) != id(Base.p2) + assert id(Mod.p3) == id(Base.p3) + assert repr(Mod.p2) == repr(Base.p2) # must be a clone + assert repr(Mod.p3) == repr(Base.p3) # must be a clone + assert Mod.p1.default == True + # manipulating default makes Base.p1 and Mod.p1 match + Mod.p1.default = False + assert repr(Mod.p1) == repr(Base.p1) def test_Export(): - class Mod: + class Mod(HasAccessibles): param = Parameter('description1', datatype=BoolType, default=False) assert Mod.param.export == '_param' diff --git a/test/test_properties.py b/test/test_properties.py index a7dbb6f..9e68765 100644 --- a/test/test_properties.py +++ b/test/test_properties.py @@ -159,3 +159,26 @@ def test_Property_override(): a = 's' assert 'can not set' in str(e.value) + + +def test_Properties_mro(): + class A(HasProperties): + p = Property('base', StringType(), 'base', export='always') + + class B(A): + pass + + class C(A): + p = Property('sub', FloatRange(), extname='p') + + class D(C, B): + p = 1 + + class E(B, C): + p = 2 + + assert B().exportProperties() == {'_p': 'base'} + assert D().exportProperties() == {'p': 1.0} + # in an older implementation the following would fail, as B.p is constructed first + # and then B.p overrides C.p + assert E().exportProperties() == {'p': 2.0}