fix parameter inheritance using MRO

+ no update on unhchanged values within 1 sec

Change-Id: I3e3d50bb5541e8d4da2badc3133d243dd0a3b892
This commit is contained in:
zolliker 2021-10-06 08:32:47 +02:00
parent 544e42033d
commit e38cd11bfe
8 changed files with 400 additions and 164 deletions

View File

@ -102,7 +102,7 @@ class DataType(HasProperties):
self.setProperty(k, v) self.setProperty(k, v)
self.checkProperties() self.checkProperties()
except Exception as e: except Exception as e:
raise ProgrammingError(str(e)) raise ProgrammingError(str(e)) from None
def get_info(self, **kwds): def get_info(self, **kwds):
"""prepare dict for export or repr """prepare dict for export or repr
@ -194,7 +194,7 @@ class FloatRange(DataType):
try: try:
value = float(value) value = float(value)
except Exception: 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 # map +/-infty to +/-max possible number
value = clamp(-sys.float_info.max, value, sys.float_info.max) value = clamp(-sys.float_info.max, value, sys.float_info.max)
@ -268,7 +268,7 @@ class IntRange(DataType):
fvalue = float(value) fvalue = float(value)
value = int(value) value = int(value)
except Exception: 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: if not self.min <= value <= self.max or round(fvalue) != fvalue:
raise BadValueError('%r should be an int between %d and %d' % raise BadValueError('%r should be an int between %d and %d' %
(value, self.min, self.max)) (value, self.min, self.max))
@ -371,7 +371,7 @@ class ScaledInteger(DataType):
try: try:
value = float(value) value = float(value)
except Exception: 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), prec = max(self.scale, abs(value * self.relative_resolution),
self.absolute_resolution) self.absolute_resolution)
if self.min - prec <= value <= self.max + prec: if self.min - prec <= value <= self.max + prec:
@ -454,7 +454,7 @@ class EnumType(DataType):
try: try:
return self._enum[value] return self._enum[value]
except (KeyError, TypeError): # TypeError will be raised when value is not hashable 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): def from_string(self, text):
return self(text) return self(text)
@ -533,7 +533,7 @@ class BLOBType(DataType):
if self.minbytes < other.minbytes or self.maxbytes > other.maxbytes: if self.minbytes < other.minbytes or self.maxbytes > other.maxbytes:
raise BadValueError('incompatible datatypes') raise BadValueError('incompatible datatypes')
except AttributeError: except AttributeError:
raise BadValueError('incompatible datatypes') raise BadValueError('incompatible datatypes') from None
class StringType(DataType): class StringType(DataType):
@ -572,7 +572,7 @@ class StringType(DataType):
try: try:
value.encode('ascii') value.encode('ascii')
except UnicodeEncodeError: except UnicodeEncodeError:
raise BadValueError('%r contains non-ascii character!' % value) raise BadValueError('%r contains non-ascii character!' % value) from None
size = len(value) size = len(value)
if size < self.minchars: if size < self.minchars:
raise BadValueError( raise BadValueError(
@ -606,7 +606,7 @@ class StringType(DataType):
self.isUTF8 > other.isUTF8: self.isUTF8 > other.isUTF8:
raise BadValueError('incompatible datatypes') raise BadValueError('incompatible datatypes')
except AttributeError: 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), # 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): def __init__(self, maxchars=None):
if maxchars is None: if maxchars is None:
maxchars = UNLIMITED maxchars = UNLIMITED
super(TextType, self).__init__(0, maxchars) super().__init__(0, maxchars)
def __repr__(self): def __repr__(self):
if self.maxchars == UNLIMITED: if self.maxchars == UNLIMITED:
@ -771,7 +771,7 @@ class ArrayOf(DataType):
raise BadValueError('incompatible datatypes') raise BadValueError('incompatible datatypes')
self.members.compatible(other.members) self.members.compatible(other.members)
except AttributeError: except AttributeError:
raise BadValueError('incompatible datatypes') raise BadValueError('incompatible datatypes') from None
class TupleOf(DataType): class TupleOf(DataType):
@ -811,7 +811,7 @@ class TupleOf(DataType):
return tuple(sub(elem) return tuple(sub(elem)
for sub, elem in zip(self.members, value)) for sub, elem in zip(self.members, value))
except Exception as exc: 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): def export_value(self, value):
"""returns a python object fit for serialisation""" """returns a python object fit for serialisation"""
@ -894,7 +894,7 @@ class StructOf(DataType):
return ImmutableDict((str(k), self.members[k](v)) return ImmutableDict((str(k), self.members[k](v))
for k, v in list(value.items())) for k, v in list(value.items()))
except Exception as exc: 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): def export_value(self, value):
"""returns a python object fit for serialisation""" """returns a python object fit for serialisation"""
@ -924,7 +924,7 @@ class StructOf(DataType):
if mandatory: if mandatory:
raise BadValueError('incompatible datatypes') raise BadValueError('incompatible datatypes')
except (AttributeError, TypeError, KeyError): except (AttributeError, TypeError, KeyError):
raise BadValueError('incompatible datatypes') raise BadValueError('incompatible datatypes') from None
class CommandType(DataType): class CommandType(DataType):
@ -958,7 +958,7 @@ class CommandType(DataType):
argstr = repr(self.argument) if self.argument else '' argstr = repr(self.argument) if self.argument else ''
if self.result is None: if self.result is None:
return 'CommandType(%s)' % argstr return 'CommandType(%s)' % argstr
return 'CommandType(%s)->%s' % (argstr, repr(self.result)) return 'CommandType(%s, %s)' % (argstr, repr(self.result))
def __call__(self, value): def __call__(self, value):
"""return the validated argument value or raise""" """return the validated argument value or raise"""
@ -987,7 +987,7 @@ class CommandType(DataType):
if self.result != other.result: # not both are None if self.result != other.result: # not both are None
other.result.compatible(self.result) other.result.compatible(self.result)
except AttributeError: except AttributeError:
raise BadValueError('incompatible datatypes') raise BadValueError('incompatible datatypes') from None
# internally used datatypes (i.e. only for programming the SEC-node) # 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""" returns the value or raises an appropriate exception"""
if isinstance(value, DataType): if isinstance(value, DataType):
return value 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): def export_value(self, value):
"""if needed, reformat value for transport""" """if needed, reformat value for transport"""
@ -1034,6 +1037,13 @@ class ValueType(DataType):
""" """
raise NotImplementedError 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): class NoneOr(DataType):
"""validates a None or smth. else""" """validates a None or smth. else"""
@ -1080,7 +1090,7 @@ UInt64 = IntRange(0, (1 << 64) - 1)
# Goodie: Convenience Datatypes for Programming # Goodie: Convenience Datatypes for Programming
class LimitsType(TupleOf): class LimitsType(TupleOf):
def __init__(self, members): def __init__(self, members):
TupleOf.__init__(self, members, members) super().__init__(members, members)
def __call__(self, value): def __call__(self, value):
limits = TupleOf.__call__(self, value) limits = TupleOf.__call__(self, value)
@ -1092,7 +1102,7 @@ class LimitsType(TupleOf):
class StatusType(TupleOf): class StatusType(TupleOf):
# shorten initialisation and allow access to status enumMembers from status values # shorten initialisation and allow access to status enumMembers from status values
def __init__(self, enum): def __init__(self, enum):
TupleOf.__init__(self, EnumType(enum), StringType()) super().__init__(EnumType(enum), StringType())
self._enum = enum self._enum = enum
def __getattr__(self, key): def __getattr__(self, key):
@ -1164,9 +1174,9 @@ def get_datatype(json, pname=''):
kwargs = json.copy() kwargs = json.copy()
base = kwargs.pop('type') base = kwargs.pop('type')
except (TypeError, KeyError, AttributeError): 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: try:
return DATATYPES[base](pname=pname, **kwargs) return DATATYPES[base](pname=pname, **kwargs)
except Exception as e: 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

View File

@ -25,9 +25,10 @@
import sys import sys
import time import time
from collections import OrderedDict
from secop.datatypes import ArrayOf, BoolType, EnumType, FloatRange, \ 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, \ from secop.errors import BadValueError, ConfigError, InternalError, \
ProgrammingError, SECoPError, SilentError, secop_error ProgrammingError, SECoPError, SilentError, secop_error
from secop.lib import formatException, mkthread 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): class HasAccessibles(HasProperties):
"""base class of module """base class of Module
joining the class's properties, parameters and commands dicts with joining the class's properties, parameters and commands dicts with
those of base classes. those of base classes.
@ -52,40 +53,43 @@ class HasAccessibles(HasProperties):
super().__init_subclass__() super().__init_subclass__()
# merge accessibles from all sub-classes, treat overrides # merge accessibles from all sub-classes, treat overrides
# for now, allow to use also the old syntax (parameters/commands dict) # for now, allow to use also the old syntax (parameters/commands dict)
accessibles = {} accessibles = OrderedDict() # dict of accessibles
for base in reversed(cls.__bases__): merged_properties = {} # dict of dict of merged properties
accessibles.update(getattr(base, 'accessibles', {})) new_names = [] # list of names of new accessibles
newaccessibles = {k: v for k, v in cls.__dict__.items() if isinstance(v, Accessible)} for base in reversed(cls.__mro__):
for aname, aobj in list(accessibles.items()): for key, value in base.__dict__.items():
value = getattr(cls, aname, None) if isinstance(value, Accessible):
if not isinstance(value, Accessible): # else override is already done in __set_name__ value.updateProperties(merged_properties.setdefault(key, {}))
if value is None: if base == cls and key not in accessibles:
accessibles.pop(aname) new_names.append(key)
else: accessibles[key] = value
# this is either a method overwriting a command elif key in accessibles:
# or a value overwriting a property value or parameter default # either a bare value overriding a parameter
anew = aobj.override(value) # or a method overriding a command
newaccessibles[aname] = anew aobj = aobj.copy()
setattr(cls, aname, anew) aobj.override(value)
anew.__set_name__(cls, aname) accessibles[key] = aobj
for aname, aobj in accessibles.items():
ordered = {} if aobj != getattr(cls, aname, None):
for aname in cls.__dict__.get('paramOrder', ()): 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: if aname in accessibles:
ordered[aname] = accessibles.pop(aname) accessibles.move_to_end(aname)
elif aname in newaccessibles:
ordered[aname] = newaccessibles.pop(aname)
# ignore unknown names # ignore unknown names
# starting from old accessibles not mentioned, append items from 'order' # move (3) to the end
accessibles.update(ordered) for aname in new_names:
# then new accessibles not mentioned accessibles.move_to_end(aname)
accessibles.update(newaccessibles) # note: for python < 3.6 the order of inherited items is not ensured between
# declarations within the same class
cls.accessibles = accessibles cls.accessibles = accessibles
# Correct naming of EnumTypes # Correct naming of EnumTypes
for k, v in accessibles.items(): # moved to Parameter.__set_name__
if isinstance(v, Parameter) and isinstance(v.datatype, EnumType):
v.datatype.set_name(k)
# check validity of Parameter entries # check validity of Parameter entries
for pname, pobj in accessibles.items(): for pname, pobj in accessibles.items():
@ -318,8 +322,9 @@ class Module(HasAccessibles):
paramobj = self.accessibles.get(paramname, None) paramobj = self.accessibles.get(paramname, None)
# paramobj might also be a command (not sure if this is needed) # paramobj might also be a command (not sure if this is needed)
if paramobj: if paramobj:
if propname == 'datatype': # no longer needed, this conversion is done by DataTypeType.__call__:
propvalue = get_datatype(propvalue, k) # if propname == 'datatype':
# propvalue = get_datatype(propvalue, k)
try: try:
paramobj.setProperty(propname, propvalue) paramobj.setProperty(propname, propvalue)
except KeyError: except KeyError:
@ -347,6 +352,10 @@ class Module(HasAccessibles):
self.valueCallbacks[pname] = [] self.valueCallbacks[pname] = []
self.errorCallbacks[pname] = [] self.errorCallbacks[pname] = []
if not pobj.hasDatatype():
errors.append('%s needs a datatype' % pname)
continue
if pname in cfgdict: if pname in cfgdict:
if not pobj.readonly and pobj.initwrite is not False: if not pobj.readonly and pobj.initwrite is not False:
# parameters given in cfgdict have to call write_<pname> # parameters given in cfgdict have to call write_<pname>
@ -393,11 +402,15 @@ class Module(HasAccessibles):
cfgdict.pop(k) cfgdict.pop(k)
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
# self.log.exception(formatExtendedStack()) # 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 # Modify units AFTER applying the cfgdict
for k, v in self.parameters.items(): for pname, pobj in self.parameters.items():
dt = v.datatype dt = pobj.datatype
if '$' in dt.unit: if '$' in dt.unit:
dt.setProperty('unit', dt.unit.replace('$', self.parameters['value'].datatype.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(): for pname, p in self.parameters.items():
try: try:
p.checkProperties() p.checkProperties()
except ConfigError: except ConfigError as e:
errors.append('%s: %s' % (pname, e)) errors.append('%s: %s' % (pname, e))
if errors: if errors:
raise ConfigError(errors) raise ConfigError(errors)
@ -426,7 +439,7 @@ class Module(HasAccessibles):
"""announce a changed value or readerror""" """announce a changed value or readerror"""
pobj = self.parameters[pname] pobj = self.parameters[pname]
timestamp = timestamp or time.time() 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: if value is not None:
pobj.value = value # store the value even in case of error pobj.value = value # store the value even in case of error
if err: if err:
@ -439,8 +452,10 @@ class Module(HasAccessibles):
pobj.value = pobj.datatype(value) pobj.value = pobj.datatype(value)
except Exception as e: except Exception as e:
err = secop_error(e) err = secop_error(e)
if not changed: if not changed and timestamp < ((pobj.timestamp or 0)
return # experimental: do not update unchanged values within 1 sec + self.DISPATCHER.OMIT_UNCHANGED_WITHIN):
# no change within short time -> omit
return
pobj.timestamp = timestamp pobj.timestamp = timestamp
pobj.readerror = err pobj.readerror = err
if pobj.export: if pobj.export:

View File

@ -35,9 +35,17 @@ UNSET = object() # an argument not given, not even None
class Accessible(HasProperties): 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): def init(self, kwds):
# do not use self.propertyValues.update here, as no invalid values should be # do not use self.propertyValues.update here, as no invalid values should be
@ -45,42 +53,46 @@ class Accessible(HasProperties):
for k, v in kwds.items(): for k, v in kwds.items():
self.setProperty(k, v) 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): def as_dict(self):
return self.propertyValues return self.propertyValues
def override(self, value=UNSET, **kwds): def override(self, value):
"""return a copy, overridden by a bare attribute """override with a bare value"""
and/or some properties"""
raise NotImplementedError raise NotImplementedError
def copy(self): def copy(self, **kwds):
"""return a (deep) copy of ourselfs""" """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 raise NotImplementedError
def for_export(self): def for_export(self):
"""prepare for serialisation""" """prepare for serialisation"""
raise NotImplementedError raise NotImplementedError
def hasDatatype(self):
return 'datatype' in self.propertyValues
def __repr__(self): def __repr__(self):
props = [] props = []
for k, prop in sorted(self.propertyDict.items()): for k, v in sorted(self.propertyValues.items()):
v = self.propertyValues.get(k, prop.default)
if v != prop.default:
props.append('%s=%r' % (k, v)) props.append('%s=%r' % (k, v))
return '%s(%s)' % (self.__class__.__name__, ', '.join(props)) return '%s(%s)' % (self.__class__.__name__, ', '.join(props))
@ -100,7 +112,7 @@ class Parameter(Accessible):
extname='description', mandatory=True, export='always') extname='description', mandatory=True, export='always')
datatype = Property( datatype = Property(
'datatype of the Parameter (SECoP datainfo)', DataTypeType(), 'datatype of the Parameter (SECoP datainfo)', DataTypeType(),
extname='datainfo', mandatory=True, export='always') extname='datainfo', mandatory=True, export='always', default=ValueType())
readonly = Property( readonly = Property(
'not changeable via SECoP (default True)', BoolType(), 'not changeable via SECoP (default True)', BoolType(),
extname='readonly', default=True, export='always') extname='readonly', default=True, export='always')
@ -160,9 +172,13 @@ class Parameter(Accessible):
timestamp = 0 timestamp = 0
readerror = None 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__() 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 not isinstance(datatype, DataType):
if isinstance(datatype, type) and issubclass(datatype, DataType): if isinstance(datatype, type) and issubclass(datatype, DataType):
# goodie: make an instance from a class (forgotten ()???) # goodie: make an instance from a class (forgotten ()???)
@ -174,15 +190,15 @@ class Parameter(Accessible):
if 'default' in kwds: if 'default' in kwds:
self.default = datatype(kwds['default']) 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: if description is not None:
self.description = inspect.cleandoc(description) kwds['description'] = inspect.cleandoc(description)
# save for __set_name__ self.init(kwds)
self._inherit = inherit
self._unit = unit # for legacy code only if inherit:
self._constant = constant self.ownProperties.update(self.propertyValues)
else:
self.ownProperties = {k: getattr(self, k) for k in self.propertyDict}
def __get__(self, instance, owner): def __get__(self, instance, owner):
# not used yet # not used yet
@ -195,57 +211,74 @@ class Parameter(Accessible):
def __set_name__(self, owner, name): def __set_name__(self, owner, name):
self.name = name self.name = name
if isinstance(self.datatype, EnumType):
self.datatype.set_name(name)
if self._inherit: if self.export is True:
self.inherit(Parameter, owner) 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 def copy(self, **kwds):
missing_properties = [pname for pname in ('description', 'datatype') if pname not in self.propertyValues] """return a (deep) copy of ourselfs
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)
if self._constant is not None: :param kwds: override given properties
constant = self.datatype(self._constant) """
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 # The value of the `constant` property should be the
# serialised version of the constant, or unset # serialised version of the constant, or unset
self.constant = self.datatype.export_value(constant) self.constant = self.datatype.export_value(constant)
self.readonly = True self.readonly = True
if 'default' in self.propertyValues: if 'default' in self.propertyValues:
# fixes in case datatype has changed # fixes in case datatype has changed
try: try:
self.datatype(self.default) self.default = self.datatype(self.default)
except BadValueError: except BadValueError:
# clear default, if it does not match datatype # clear default, if it does not match datatype
self.propertyValues.pop('default') 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): def export_value(self):
return self.datatype.export_value(self.value) return self.datatype.export_value(self.value)
@ -255,15 +288,23 @@ class Parameter(Accessible):
def getProperties(self): def getProperties(self):
"""get also properties of datatype""" """get also properties of datatype"""
super_prop = super().getProperties().copy() super_prop = super().getProperties().copy()
if self.datatype:
super_prop.update(self.datatype.getProperties()) super_prop.update(self.datatype.getProperties())
return super_prop return super_prop
def setProperty(self, key, value): def setProperty(self, key, value):
"""set also properties of datatype""" """set also properties of datatype"""
try:
if key in self.propertyDict: if key in self.propertyDict:
super().setProperty(key, value) super().setProperty(key, value)
else: else:
try:
self.datatype.setProperty(key, value) 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): def checkProperties(self):
super().checkProperties() super().checkProperties()
@ -336,10 +377,9 @@ class Command(Accessible):
if self.func is None: if self.func is None:
raise ProgrammingError('Command %s.%s must be used as a method decorator' % raise ProgrammingError('Command %s.%s must be used as a method decorator' %
(owner.__name__, name)) (owner.__name__, name))
if self._inherit:
self.inherit(Command, owner)
self.datatype = CommandType(self.argument, self.result) self.datatype = CommandType(self.argument, self.result)
self.ownProperties = self.propertyValues.copy()
if self.export is True: if self.export is True:
predefined_cls = PREDEFINED_ACCESSIBLES.get(name, None) predefined_cls = PREDEFINED_ACCESSIBLES.get(name, None)
if predefined_cls is Command: if predefined_cls is Command:
@ -347,39 +387,77 @@ class Command(Accessible):
elif predefined_cls is None: elif predefined_cls is None:
self.export = '_' + name self.export = '_' + name
else: 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): def __get__(self, obj, owner=None):
if obj is None: if obj is None:
return self return self
if not self.func: 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) return self.func.__get__(obj, owner)
def __call__(self, func): def __call__(self, func):
"""called when used as decorator"""
if 'description' not in self.propertyValues and func.__doc__: if 'description' not in self.propertyValues and func.__doc__:
self.description = inspect.cleandoc(func.__doc__) self.description = inspect.cleandoc(func.__doc__)
self.func = func self.func = func
return self return self
def copy(self): def copy(self, **kwds):
"""return a (deep) copy of ourselfs
:param kwds: override given properties
"""
res = type(self)() res = type(self)()
res.name = self.name res.name = self.name
res.func = self.func res.func = self.func
res.init(self.propertyValues) res.init(self.propertyValues)
res.init(kwds)
if res.argument: if res.argument:
res.argument = res.argument.copy() res.argument = res.argument.copy()
if res.result: if res.result:
res.result = res.result.copy() res.result = res.result.copy()
res.datatype = CommandType(res.argument, res.result) self.finish()
return res return res
def override(self, value=UNSET, **kwds): def updateProperties(self, merged_properties):
res = self.copy() """update merged_properties with our own properties"""
res.init(kwds) merged_properties.update(self.ownProperties)
if value is not UNSET:
res.func = value def override(self, value):
return res """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): def do(self, module_obj, argument):
"""perform function call """perform function call

View File

@ -61,6 +61,8 @@ def make_update(modulename, pobj):
class Dispatcher: class Dispatcher:
OMIT_UNCHANGED_WITHIN = 1 # do not send unchanged updates within 1 sec
def __init__(self, name, logger, options, srv): def __init__(self, name, logger, options, srv):
# to avoid errors, we want to eat all options here # to avoid errors, we want to eat all options here
self.equipment_id = options.pop('id', name) self.equipment_id = options.pop('id', name)

View File

@ -71,6 +71,8 @@ class Data:
class DispatcherStub: class DispatcherStub:
OMIT_UNCHANGED_WITHIN = 0
def __init__(self, updates): def __init__(self, updates):
self.updates = updates self.updates = updates
@ -106,7 +108,6 @@ def test_IOHandler():
group1 = Hdl('group1', 'SIMPLE?', '%g') group1 = Hdl('group1', 'SIMPLE?', '%g')
group2 = Hdl('group2', 'CMD?%(channel)d', '%g,%s,%d') group2 = Hdl('group2', 'CMD?%(channel)d', '%g,%s,%d')
class Module1(Module): class Module1(Module):
channel = Property('the channel', IntRange(), default=3) channel = Property('the channel', IntRange(), default=3)
loop = Property('the loop', IntRange(), default=2) loop = Property('the loop', IntRange(), default=2)

View File

@ -26,14 +26,16 @@ import threading
import pytest import pytest
from secop.datatypes import BoolType, FloatRange, StringType from secop.datatypes import BoolType, FloatRange, StringType, IntRange, CommandType
from secop.errors import ProgrammingError from secop.errors import ProgrammingError, ConfigError
from secop.modules import Communicator, Drivable, Module from secop.modules import Communicator, Drivable, Readable, Module
from secop.params import Command, Parameter from secop.params import Command, Parameter
from secop.poller import BasicPoller from secop.poller import BasicPoller
class DispatcherStub: class DispatcherStub:
OMIT_UNCHANGED_WITHIN = 0
def __init__(self, updates): def __init__(self, updates):
self.updates = updates self.updates = updates
@ -51,6 +53,9 @@ class LoggerStub:
info = warning = exception = debug info = warning = exception = debug
logger = LoggerStub()
class ServerStub: class ServerStub:
def __init__(self, updates): def __init__(self, updates):
self.dispatcher = DispatcherStub(updates) self.dispatcher = DispatcherStub(updates)
@ -142,7 +147,6 @@ def test_ModuleMagic():
sortcheck2 = ['status', 'pollinterval', 'target', 'stop', sortcheck2 = ['status', 'pollinterval', 'target', 'stop',
'a1', 'a2', 'cmd2', 'param1', 'param2', 'cmd', 'value', 'b2'] 'a1', 'a2', 'cmd2', 'param1', 'param2', 'cmd', 'value', 'b2']
logger = LoggerStub()
updates = {} updates = {}
srv = ServerStub(updates) srv = ServerStub(updates)
@ -228,3 +232,103 @@ def test_ModuleMagic():
o.earlyInit() o.earlyInit()
for o in objects: for o in objects:
o.initModule() 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'},
}

View File

@ -88,15 +88,18 @@ def test_Override():
p1 = Parameter(default=True) p1 = Parameter(default=True)
p2 = Parameter() # override without change p2 = Parameter() # override without change
assert Mod.p1 != Base.p1 assert id(Mod.p1) != id(Base.p1)
assert Mod.p2 != Base.p2 assert id(Mod.p2) != id(Base.p2)
assert Mod.p3 == Base.p3 assert id(Mod.p3) == id(Base.p3)
assert repr(Mod.p2) == repr(Base.p2) # must be a clone
assert id(Mod.p2) != id(Base.p2) # must be a new object assert repr(Mod.p3) == repr(Base.p3) # must be a clone
assert repr(Mod.p2) == repr(Base.p2) # but 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(): def test_Export():
class Mod: class Mod(HasAccessibles):
param = Parameter('description1', datatype=BoolType, default=False) param = Parameter('description1', datatype=BoolType, default=False)
assert Mod.param.export == '_param' assert Mod.param.export == '_param'

View File

@ -159,3 +159,26 @@ def test_Property_override():
a = 's' a = 's'
assert 'can not set' in str(e.value) 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}