From f4d572966cbab65dc50852db93978504fdc3fe6a Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Fri, 11 Oct 2019 15:11:04 +0200 Subject: [PATCH] use Properties from secop.properties for datatypes this change is triggered by the fact, that assigining a unit in the config file did no longer work. this change has several implications: 1) secop.properties must not import secop.datatypes: - as ValueType can not be imported, the default behaviour with 'mandatory' and 'default' arguments was slightly changed - instead of checking for DataType when exporting, a try/except was used 2) the datatype of datatype properties is sometimes not yet defined. a stub is used in this cases instead, which is later replaced by the proper datatype. The number of stubs may be reduced, but this should be done in a later change, as the diff will be much less readable. 3) in config files, datatype properties can be changed like parameter properties. HasProperties.setProperties/checkProperties/getProperties are overridden for this. the config editor seems still to work, an issue (probably py3) had to be fixed there Change-Id: I1efddf51f2c760510e913dbcaa099e8a89c9cab5 Reviewed-on: https://forge.frm2.tum.de/review/c/sine2020/secop/playground/+/21399 Tested-by: JenkinsCodeReview Reviewed-by: Enrico Faulhaber Reviewed-by: Markus Zolliker --- secop/datatypes.py | 297 +++++++++++++++++----------- secop/gui/cfg_editor/config_file.py | 10 +- secop/metaclass.py | 2 +- secop/modules.py | 7 +- secop/params.py | 42 ++-- secop/properties.py | 38 ++-- secop/protocol/interface/tcp.py | 5 +- test/test_datatypes.py | 70 ++++++- test/test_modules.py | 27 ++- test/test_properties.py | 31 ++- 10 files changed, 356 insertions(+), 173 deletions(-) diff --git a/secop/datatypes.py b/secop/datatypes.py index 19a1b8f..55a6ad7 100644 --- a/secop/datatypes.py +++ b/secop/datatypes.py @@ -24,11 +24,13 @@ # pylint: disable=abstract-method +import sys from base64 import b64decode, b64encode -from secop.errors import ProgrammingError, ProtocolError, BadValueError +from secop.errors import ProgrammingError, ProtocolError, BadValueError, ConfigError from secop.lib.enum import Enum from secop.parse import Parser +from secop.properties import HasProperties, Property # Only export these classes for 'from secop.datatypes import *' @@ -44,14 +46,14 @@ __all__ = [ # *DEFAULT* limits for IntRange/ScaledIntegers transport serialisation DEFAULT_MIN_INT = -16777216 DEFAULT_MAX_INT = 16777216 +UNLIMITED = 1 << 64 # internal limit for integers, is probably high enough for any datatype size Parser = Parser() # base class for all DataTypes -class DataType: +class DataType(HasProperties): IS_COMMAND = False unit = '' - fmtstr = '%r' default = None def __call__(self, value): @@ -89,23 +91,23 @@ class DataType: if unit is given, use it, else use the unit of the datatype (if any)""" raise NotImplementedError - def set_prop(self, key, value, default, func=lambda x:x): - """set an optional datatype property and store the default""" - self._defaults[key] = default - if value is None: - value = default - setattr(self, key, func(value)) + def set_properties(self, **kwds): + """init datatype properties""" + try: + for k,v in kwds.items(): + self.setProperty(k, v) + self.checkProperties() + except Exception as e: + raise ProgrammingError(str(e)) def get_info(self, **kwds): """prepare dict for export or repr get a dict with all items different from default plus mandatory keys from kwds""" - for k,v in self._defaults.items(): - value = getattr(self, k) - if value != v: - kwds[k] = value - return kwds + result = self.exportProperties() + result.update(kwds) + return result def copy(self): """make a deep copy of the datatype""" @@ -114,29 +116,62 @@ class DataType: return get_datatype(self.export_datatype()) +class Stub(DataType): + """incomplete datatype, to be replaced with a proper one later during module load + + this workaround because datatypes need properties with datatypes defined later + """ + def __init__(self, datatype_name, *args): + super().__init__() + self.name = datatype_name + self.args = args + + def __call__(self, value): + """validate""" + return value + + @classmethod + def fix_datatypes(cls): + """replace stubs with real datatypes + + for all DataType classes in this module + to be called after all involved datatypes are defined + """ + for dtcls in globals().values(): + if isinstance(dtcls, type) and issubclass(dtcls, DataType): + for prop in dtcls.properties.values(): + stub = prop.datatype + if isinstance(stub, cls): + prop.datatype = globals()[stub.name](*stub.args) + +# SECoP types: + class FloatRange(DataType): """Restricted float type""" + properties = { + 'min': Property('low limit', Stub('FloatRange'), extname='min', default=float('-inf')), + 'max': Property('high limit', Stub('FloatRange'), extname='max', default=float('+inf')), + 'unit': Property('physical unit', Stub('StringType'), extname='unit', default=''), + 'fmtstr': Property('format string', Stub('StringType'), extname='fmtstr', default='%g'), + 'absolute_resolution': Property('absolute resolution', Stub('FloatRange', 0), + extname='absolute_resolution', default=0.0), + 'relative_resolution': Property('relative resolution', Stub('FloatRange', 0), + extname='relative_resolution', default=1.2e-7), + } - def __init__(self, minval=None, maxval=None, unit=None, fmtstr=None, - absolute_resolution=None, relative_resolution=None,): - self._defaults = {} - self.set_prop('min', minval, float('-inf'), float) - self.set_prop('max', maxval, float('+inf'), float) - self.set_prop('unit', unit, '', str) - self.set_prop('fmtstr', fmtstr, '%g', str) - self.set_prop('absolute_resolution', absolute_resolution, 0.0, float) - self.set_prop('relative_resolution', relative_resolution, 1.2e-7, float) + def __init__(self, minval=None, maxval=None, **kwds): + super().__init__() + if minval is not None: + kwds['min'] = minval + if maxval is not None: + kwds['max'] = maxval + self.set_properties(**kwds) + + def checkProperties(self): self.default = 0 if self.min <= 0 <= self.max else self.min - - # check values - if self.min > self.max: - raise BadValueError('max must be larger then min!') + super().checkProperties() if '%' not in self.fmtstr: - raise BadValueError('Invalid fmtstr!') - if self.absolute_resolution < 0: - raise BadValueError('absolute_resolution MUST be >=0') - if self.relative_resolution < 0: - raise BadValueError('relative_resolution MUST be >=0') + raise ConfigError('Invalid fmtstr!') def export_datatype(self): return self.get_info(type='double') @@ -180,32 +215,34 @@ class FloatRange(DataType): return self.fmtstr % value + class IntRange(DataType): """Restricted int type""" + properties = { + 'min': Property('minimum value', Stub('IntRange', -UNLIMITED, UNLIMITED), extname='min', mandatory=True), + 'max': Property('maximum value', Stub('IntRange', -UNLIMITED, UNLIMITED), extname='max', mandatory=True), + # a unit on an int is now allowed in SECoP, but do we need them in Frappy? + # 'unit': Property('physical unit', StringType(), extname='unit', default=''), + } def __init__(self, minval=None, maxval=None): - self.min = DEFAULT_MIN_INT if minval is None else int(minval) - self.max = DEFAULT_MAX_INT if maxval is None else int(maxval) - self.default = 0 if self.min <= 0 <= self.max else self.min - # a unit on an int is now allowed in SECoP, but do we need them in Frappy? - # self.set_prop('unit', unit, '', str) + super().__init__() + self.set_properties(min=DEFAULT_MIN_INT if minval is None else minval, + max=DEFAULT_MAX_INT if maxval is None else maxval) - # check values - if self.min > self.max: - raise BadValueError('Max must be larger then min!') + def checkProperties(self): + self.default = 0 if self.min <= 0 <= self.max else self.min + super().checkProperties() def export_datatype(self): - return dict(type='int', min=self.min, max=self.max) + return self.get_info(type='int') def __call__(self, value): try: value = int(value) - if value < self.min: + if not (self.min <= value <= self.max) or int(value) != value: raise BadValueError('%r should be an int between %d and %d' % - (value, self.min, self.max or 0)) - if value > self.max: - raise BadValueError('%r should be an int between %d and %d' % - (value, self.min or 0, self.max)) + (value, self.min, self.max)) return value except Exception: raise BadValueError('Can not convert %r to int' % value) @@ -234,36 +271,54 @@ class ScaledInteger(DataType): note: limits are for the scaled value (i.e. the internal value) the scale is only used for calculating to/from transport serialisation""" + properties = { + 'scale': Property('scale factor', FloatRange(sys.float_info.min), extname='scale', mandatory=True), + 'min': Property('low limit', FloatRange(), extname='min', mandatory=True), + 'max': Property('high limit', FloatRange(), extname='max', mandatory=True), + 'unit': Property('physical unit', Stub('StringType'), extname='unit', default=''), + 'fmtstr': Property('format string', Stub('StringType'), extname='fmtstr', default='%g'), + 'absolute_resolution': Property('absolute resolution', FloatRange(0), + extname='absolute_resolution', default=0.0), + 'relative_resolution': Property('relative resolution', FloatRange(0), + extname='relative_resolution', default=1.2e-7), + } - def __init__(self, scale, minval=None, maxval=None, unit=None, fmtstr=None, - absolute_resolution=None, relative_resolution=None,): - self._defaults = {} - self.scale = float(scale) - if not self.scale > 0: - raise BadValueError('Scale MUST be positive!') - self.set_prop('unit', unit, '', str) - self.set_prop('fmtstr', fmtstr, '%g', str) - self.set_prop('absolute_resolution', absolute_resolution, self.scale, float) - self.set_prop('relative_resolution', relative_resolution, 1.2e-7, float) + def __init__(self, scale, minval=None, maxval=None, absolute_resolution=None, **kwds): + super().__init__() + scale = float(scale) + if absolute_resolution is None: + absolute_resolution = scale + self.set_properties(scale=scale, + min=DEFAULT_MIN_INT * scale if minval is None else float(minval), + max=DEFAULT_MAX_INT * scale if maxval is None else float(maxval), + absolute_resolution=absolute_resolution, + **kwds) - self.min = DEFAULT_MIN_INT * self.scale if minval is None else float(minval) - self.max = DEFAULT_MAX_INT * self.scale if maxval is None else float(maxval) + def checkProperties(self): self.default = 0 if self.min <= 0 <= self.max else self.min + super().checkProperties() # check values - if self.min > self.max: - raise BadValueError('Max must be larger then min!') if '%' not in self.fmtstr: raise BadValueError('Invalid fmtstr!') - if self.absolute_resolution < 0: - raise BadValueError('absolute_resolution MUST be >=0') - if self.relative_resolution < 0: - raise BadValueError('relative_resolution MUST be >=0') # Remark: Datatype.copy() will round min, max to a multiple of self.scale # this should be o.k. + def exportProperties(self): + result = super().exportProperties() + if self.absolute_resolution == 0: + result['absolute_resolution'] = 0 + elif self.absolute_resolution == self.scale: + result.pop('absolute_resolution', 0) + return result + + def setProperty(self, key, value): + if key == 'scale' and self.absolute_resolution == self.scale: + super().setProperty('absolute_resolution', value) + super().setProperty(key, value) + def export_datatype(self): - return self.get_info(type='scaled', scale=self.scale, + return self.get_info(type='scaled', min = int((self.min + self.scale * 0.5) // self.scale), max = int((self.max + self.scale * 0.5) // self.scale)) @@ -284,7 +339,7 @@ class ScaledInteger(DataType): return value # return 'actual' value (which is more discrete than a float) def __repr__(self): - hints = self.get_info(scale='%g' % self.scale, + hints = self.get_info(scale=float('%g' % self.scale), min = int((self.min + self.scale * 0.5) // self.scale), max = int((self.max + self.scale * 0.5) // self.scale)) return 'ScaledInteger(%s)' % (', '.join('%s=%r' % kv for kv in hints.items())) @@ -313,6 +368,7 @@ class ScaledInteger(DataType): class EnumType(DataType): def __init__(self, enum_or_name='', **kwds): + super().__init__() if 'members' in kwds: kwds = dict(kwds) kwds.update(kwds['members']) @@ -353,25 +409,27 @@ class EnumType(DataType): class BLOBType(DataType): - minbytes = 0 - maxbytes = None + properties = { + 'minbytes': Property('minimum number of bytes', IntRange(0), extname='minbytes', + default=0), + 'maxbytes': Property('maximum number of bytes', IntRange(0), extname='maxbytes', + mandatory=True), + } def __init__(self, minbytes=0, maxbytes=None): + super().__init__() # if only one argument is given, use exactly that many bytes # if nothing is given, default to 255 if maxbytes is None: maxbytes = minbytes or 255 - self._defaults = {} - self.set_prop('minbytes', minbytes, 0, int) - self.maxbytes = int(maxbytes) - if self.minbytes < 0: - raise BadValueError('sizes must be bigger than or equal to 0!') - if self.minbytes > self.maxbytes: - raise BadValueError('maxbytes must be bigger than or equal to minbytes!') + self.set_properties(minbytes=minbytes, maxbytes=maxbytes) + + def checkProperties(self): self.default = b'\0' * self.minbytes + super().checkProperties() def export_datatype(self): - return self.get_info(type='blob', maxbytes=self.maxbytes) + return self.get_info(type='blob') def __repr__(self): return 'BLOBType(%d, %d)' % (self.minbytes, self.maxbytes) @@ -407,20 +465,24 @@ class BLOBType(DataType): class StringType(DataType): - MAXCHARS = 0xffffffff + properties = { + 'minchars': Property('minimum number of character points', IntRange(0, UNLIMITED), + extname='minchars', default=0), + 'maxchars': Property('maximum number of character points', IntRange(0, UNLIMITED), + extname='maxchars', default=UNLIMITED), + 'isUTF8': Property('flag telling whether encoding is UTF-8 instead of ASCII', + Stub('BoolType'), extname='isUTF8', default=False), + } - def __init__(self, minchars=0, maxchars=None, isUTF8=False): + def __init__(self, minchars=0, maxchars=None, **kwds): + super().__init__() if maxchars is None: - maxchars = minchars or self.MAXCHARS - self._defaults = {} - self.set_prop('minchars', minchars, 0, int) - self.set_prop('maxchars', maxchars, self.MAXCHARS, int) - self.set_prop('isUTF8', isUTF8, False, bool) - if self.minchars < 0: - raise BadValueError('sizes must be bigger than or equal to 0!') - if self.minchars > self.maxchars: - raise BadValueError('maxchars must be bigger than or equal to minchars!') + maxchars = minchars or UNLIMITED + self.set_properties(minchars=minchars, maxchars=maxchars, **kwds) + + def checkProperties(self): self.default = ' ' * self.minchars + super().checkProperties() def export_datatype(self): return self.get_info(type='string') @@ -455,7 +517,6 @@ class StringType(DataType): def import_value(self, value): """returns a python object from serialisation""" - # XXX: do we keep it as str str, or convert it to something else? (UTF-8 maybe?) return str(value) def from_string(self, text): @@ -473,7 +534,7 @@ class StringType(DataType): class TextType(StringType): def __init__(self, maxchars=None): if maxchars is None: - maxchars = self.MAXCHARS + maxchars = UNLIMITED super(TextType, self).__init__(0, maxchars) def __repr__(self): @@ -484,7 +545,6 @@ class TextType(StringType): return TextType(self.maxchars) -# Bool is a special enum class BoolType(DataType): default = False @@ -504,7 +564,7 @@ class BoolType(DataType): def export_value(self, value): """returns a python object fit for serialisation""" - return bool(self(value)) + return self(value) def import_value(self, value): """returns a python object from serialisation""" @@ -514,21 +574,26 @@ class BoolType(DataType): value = text return self(value) - def format_value(self, value, unit=None): return repr(bool(value)) +Stub.fix_datatypes() + # # nested types # class ArrayOf(DataType): - minlen = None - maxlen = None - members = None + properties = { + 'minlen': Property('minimum number of elements', IntRange(0), extname='minlen', + default=0), + 'maxlen': Property('maximum number of elements', IntRange(0), extname='maxlen', + mandatory=True), + } - def __init__(self, members, minlen=0, maxlen=None, unit=None): + def __init__(self, members, minlen=0, maxlen=None): + super().__init__() if not isinstance(members, DataType): raise BadValueError( 'ArrayOf only works with a DataType as first argument!') @@ -537,18 +602,22 @@ class ArrayOf(DataType): if maxlen is None: maxlen = minlen or 100 self.members = members - if unit: - self.members.unit = unit + self.set_properties(minlen=minlen, maxlen=maxlen) - self.minlen = int(minlen) - self.maxlen = int(maxlen) - if self.minlen < 0: - raise BadValueError('sizes must be > 0') - if self.maxlen < 1: - raise BadValueError('Maximum size must be >= 1!') - if self.minlen > self.maxlen: - raise BadValueError('maxlen must be bigger than or equal to minlen!') - self.default = [members.default] * self.minlen + def checkProperties(self): + self.default = [self.members.default] * self.minlen + super().checkProperties() + + def getProperties(self): + """get also properties of members""" + return {**super().getProperties(), **self.members.getProperties()} + + def setProperty(self, key, value): + """set also properties of members""" + if key in self.__class__.properties: + super().setProperty(key, value) + else: + self.members.setProperty(key, value) def export_datatype(self): return dict(type='array', minlen=self.minlen, maxlen=self.maxlen, @@ -600,6 +669,7 @@ class ArrayOf(DataType): class TupleOf(DataType): def __init__(self, *members): + super().__init__() if not members: raise BadValueError('Empty tuples are not allowed!') for subtype in members: @@ -651,6 +721,7 @@ class TupleOf(DataType): class StructOf(DataType): def __init__(self, optional=None, **members): + super().__init__() self.members = members if not members: raise BadValueError('Empty structs are not allowed!') @@ -724,10 +795,9 @@ class StructOf(DataType): class CommandType(DataType): IS_COMMAND = True - argument = None - result = None def __init__(self, argument=None, result=None): + super().__init__() if argument is not None: if not isinstance(argument, DataType): raise BadValueError('CommandType: Argument type must be a DataType!') @@ -773,7 +843,7 @@ class CommandType(DataType): raise NotImplementedError -# internally used datatypes (i.e. only for programming the SEC-node +# internally used datatypes (i.e. only for programming the SEC-node) class DataTypeType(DataType): def __call__(self, value): """check if given value (a python obj) is a valid datatype @@ -816,11 +886,13 @@ class ValueType(DataType): """ raise NotImplementedError + class NoneOr(DataType): """validates a None or smth. else""" default = None def __init__(self, other): + super().__init__() self.other = other def __call__(self, value): @@ -834,6 +906,7 @@ class NoneOr(DataType): class OrType(DataType): def __init__(self, *types): + super().__init__() self.types = types self.default = self.types[0].default @@ -916,5 +989,5 @@ def get_datatype(json): raise BadValueError('a data descriptor must be a dict containing a "type" key, not %r' % json) try: return DATATYPES[base](**args) - except (TypeError, AttributeError, KeyError): - raise BadValueError('invalid data descriptor: %r' % json) + except Exception as e: + raise BadValueError('invalid data descriptor: %r (%s)' % (json, str(e))) diff --git a/secop/gui/cfg_editor/config_file.py b/secop/gui/cfg_editor/config_file.py index 391fc33..a707231 100644 --- a/secop/gui/cfg_editor/config_file.py +++ b/secop/gui/cfg_editor/config_file.py @@ -57,22 +57,22 @@ def write_config(file_name, tree_widget): blank_lines += 1 value = value.replace('\n\n', '\n.\n') value = value.replace('\n', '\n ') - itm_lines[itm] = '[%s %s]\n' % (itm.kind, itm.name) +\ + itm_lines[id(itm)] = '[%s %s]\n' % (itm.kind, itm.name) +\ value_str % (SECTIONS[itm.kind], value) # TODO params and props elif itm.kind == PARAMETER and value: - itm_lines[itm] = value_str % (itm.name, value) + itm_lines[id(itm)] = value_str % (itm.name, value) elif itm.kind == PROPERTY: prop_name = '.%s' % itm.name if par.kind == PARAMETER: prop_name = par.name + prop_name - itm_lines[itm] = value_str % (prop_name, value) + itm_lines[id(itm)] = value_str % (prop_name, value) elif itm.kind == COMMENT: temp_itm_lines = OrderedDict() for key, dict_value in itm_lines.items(): - if key == par: + if key == id(par): value = value.replace('\n', '\n# ') - temp_itm_lines[itm] = '# %s' % value + temp_itm_lines[id(itm)] = '# %s' % value temp_itm_lines[key] = dict_value itm_lines.clear() itm_lines.update(temp_itm_lines) diff --git a/secop/metaclass.py b/secop/metaclass.py index 7303fb0..7424d53 100644 --- a/secop/metaclass.py +++ b/secop/metaclass.py @@ -199,6 +199,6 @@ class ModuleMeta(PropertyMeta): # collect info about parameters and their properties for param, pobj in cls.accessibles.items(): res[param] = {} - for pn, pv in pobj.__class__.properties.items(): + for pn, pv in pobj.getProperties().items(): res[param][pn] = pv return res diff --git a/secop/modules.py b/secop/modules.py index c123904..f9748db 100644 --- a/secop/modules.py +++ b/secop/modules.py @@ -160,7 +160,7 @@ class Module(HasProperties, metaclass=ModuleMeta): if paramobj: if propname == 'datatype': paramobj.setProperty('datatype', get_datatype(cfgdict.pop(k))) - elif propname in paramobj.__class__.properties: + elif propname in paramobj.getProperties(): paramobj.setProperty(propname, cfgdict.pop(k)) else: raise ConfigError('Module %s: Parameter %r has no property %r!' % @@ -208,8 +208,9 @@ class Module(HasProperties, metaclass=ModuleMeta): # Modify units AFTER applying the cfgdict for k, v in self.parameters.items(): - if '$' in v.unit: - v.unit = v.unit.replace('$', self.parameters['value'].unit) + dt = v.datatype + if '$' in dt.unit: + dt.setProperty('unit', dt.unit.replace('$', self.parameters['value'].datatype.unit)) # 6) check complete configuration of * properties self.checkProperties() diff --git a/secop/params.py b/secop/params.py index 3670d08..f7fbaa4 100644 --- a/secop/params.py +++ b/secop/params.py @@ -45,7 +45,10 @@ class Accessible(HasProperties, CountedObj): def __init__(self, **kwds): super(Accessible, self).__init__() - self.properties.update(kwds) + # do not use self.properties.update here, as no invalid values should be + # assigned to properties, even not before checkProperties + for k,v in kwds.items(): + self.setProperty(k, v) def __repr__(self): return '%s_%d(%s)' % (self.__class__.__name__, self.ctr, ',\n\t'.join( @@ -54,6 +57,8 @@ class Accessible(HasProperties, CountedObj): def copy(self): # return a copy of ourselfs props = dict(self.properties, ctr=self.ctr) + # deep copy, as datatype might be altered from config + props['datatype'] = props['datatype'].copy() return type(self)(**props) def for_export(self): @@ -90,8 +95,6 @@ class Parameter(Accessible): extname='description', mandatory=True), 'datatype': Property('Datatype of the Parameter', DataTypeType(), extname='datainfo', mandatory=True), - 'unit': Property('[legacy] unit of the parameter. This should now be on the datatype!', StringType(), - extname='unit', default=''), # goodie, should be on the datatype! 'readonly': Property('Is the Parameter readonly? (vs. changeable via SECoP)', BoolType(), extname='readonly', default=True), 'group': Property('Optional parameter group this parameter belongs to', StringType(), @@ -99,7 +102,7 @@ class Parameter(Accessible): 'visibility': Property('Optional visibility hint', EnumType('visibility', user=1, advanced=2, expert=3), extname='visibility', default=1), 'constant': Property('Optional constant value for constant parameters', ValueType(), - extname='constant', default=None), + extname='constant', default=None, mandatory=False), 'default': Property('Default (startup) value of this parameter if it can not be read from the hardware.', ValueType(), export=False, default=None, mandatory=False), 'export': Property('Is this parameter accessible via SECoP? (vs. internal parameter)', @@ -110,7 +113,7 @@ class Parameter(Accessible): value = None timestamp = None - def __init__(self, description, datatype, ctr=None, **kwds): + def __init__(self, description, datatype, ctr=None, unit=None, **kwds): if ctr is not None: self.ctr = ctr @@ -125,6 +128,8 @@ class Parameter(Accessible): kwds['description'] = description kwds['datatype'] = datatype + if unit is not None: # for legacy code only + datatype.setProperty('unit', unit) super(Parameter, self).__init__(**kwds) # note: auto-converts True/False to 1/0 which yield the expected @@ -138,11 +143,6 @@ class Parameter(Accessible): constant = self.datatype(kwds['constant']) self.properties['constant'] = self.datatype.export_value(constant) - # helper: unit should be set on the datatype, not on the parameter! - if self.unit: - self.datatype.unit = self.unit - self.properties['unit'] = '' - # internal caching: value and timestamp of last change... self.value = self.default self.timestamp = 0 @@ -150,17 +150,20 @@ class Parameter(Accessible): def export_value(self): return self.datatype.export_value(self.value) - # helpers... - def _get_unit_(self): - return self.datatype.unit + def getProperties(self): + """get also properties of datatype""" + return {**super().getProperties(), **self.datatype.getProperties()} - def _set_unit_(self, unit): - print('DeprecationWarning: setting unit on the parameter is going to be removed') - self.datatype.unit = unit + def setProperty(self, key, value): + """set also properties of datatype""" + if key in self.__class__.properties: + super().setProperty(key, value) + else: + self.datatype.setProperty(key, value) - unit = property(_get_unit_, _set_unit_) - del _get_unit_ - del _set_unit_ + def checkProperties(self): + super().checkProperties() + self.datatype.checkProperties() class UnusedClass: @@ -220,6 +223,7 @@ class Override(CountedObj): def apply(self, obj): if isinstance(obj, Accessible): props = obj.properties.copy() + props['datatype'] = props['datatype'].copy() if isinstance(obj, Parameter): if 'constant' in self.kwds: constant = obj.datatype(self.kwds.pop('constant')) diff --git a/secop/properties.py b/secop/properties.py index 90e3f33..5fe4bd1 100644 --- a/secop/properties.py +++ b/secop/properties.py @@ -24,7 +24,6 @@ from collections import OrderedDict -from secop.datatypes import ValueType, DataType from secop.errors import ProgrammingError, ConfigError @@ -39,7 +38,7 @@ class Property: ''' # note: this is inteded to be used on base classes. # the VALUES of the properties are on the instances! - def __init__(self, description, datatype, default=None, extname='', export=False, mandatory=False, settable=True): + def __init__(self, description, datatype, default=None, extname='', export=False, mandatory=None, settable=True): if not callable(datatype): raise ValueError('datatype MUST be a valid DataType or a basic_validator') self.description = description @@ -47,7 +46,9 @@ class Property: self.datatype = datatype self.extname = extname self.export = export or bool(extname) - self.mandatory = mandatory or (default is None and not isinstance(datatype, ValueType)) + if mandatory is None: + mandatory = default is None + self.mandatory = mandatory self.settable = settable or mandatory # settable means settable from the cfg file def __repr__(self): @@ -67,7 +68,7 @@ class Properties(OrderedDict): raise ProgrammingError('setting property %r on classes is not supported!' % key) # make sure, extname is valid if export is True if not value.extname and value.export: - value.extname = '_%s' % key # generate custom kex + value.extname = '_%s' % key # generate custom key elif value.extname and not value.export: value.export = True OrderedDict.__setitem__(self, key, value) @@ -117,9 +118,11 @@ class PropertyMeta(type): class HasProperties(metaclass=PropertyMeta): properties = {} - def __init__(self, supercall_init=True): - if supercall_init: - super(HasProperties, self).__init__() + def __init__(self): + super(HasProperties, self).__init__() + self.initProperties() + + def initProperties(self): # store property values in the instance, keep descriptors on the class self.properties = {} # pre-init with properties default value (if any) @@ -128,16 +131,25 @@ class HasProperties(metaclass=PropertyMeta): self.properties[pn] = po.default def checkProperties(self): + """validates properties and checks for min... <= max...""" for pn, po in self.__class__.properties.items(): if po.export and po.mandatory: if pn not in self.properties: name = getattr(self, 'name', repr(self)) - raise ConfigError('Property %r of %r needs a value of type %r!' % (pn, name, po.datatype)) + raise ConfigError('Property %r of %s needs a value of type %r!' % (pn, name, po.datatype)) # apply validator (which may complain further) self.properties[pn] = po.datatype(self.properties[pn]) - if 'min' in self.properties and 'max' in self.properties: - if self.min > self.max: - raise ConfigError('min and max of %r need to fulfil min <= max! (is %r>%r)' % (self, self.min, self.max)) + for pn, po in self.__class__.properties.items(): + if pn.startswith('min'): + maxname = 'max' + pn[3:] + minval = self.properties[pn] + maxval = self.properties.get(maxname, minval) + if minval > maxval: + raise ConfigError('%s=%r must be <= %s=%r for %r' % (pn, minval, maxname, maxval, self)) + + + def getProperties(self): + return self.__class__.properties def exportProperties(self): # export properties which have @@ -147,8 +159,10 @@ class HasProperties(metaclass=PropertyMeta): for pn, po in self.__class__.properties.items(): val = self.properties.get(pn, None) if po.export and (po.mandatory or val != po.default): - if isinstance(po.datatype, DataType): + try: val = po.datatype.export_value(val) + except AttributeError: + pass # for properties, accept simple datatypes without export_value res[po.extname] = val return res diff --git a/secop/protocol/interface/tcp.py b/secop/protocol/interface/tcp.py index 3f2343c..e90ec63 100644 --- a/secop/protocol/interface/tcp.py +++ b/secop/protocol/interface/tcp.py @@ -200,11 +200,12 @@ class TCPServer(HasProperties, socketserver.ThreadingTCPServer): # XXX: create configurables from Metaclass! configurables = properties - def __init__(self, name, logger, options, srv): + def __init__(self, name, logger, options, srv): # pylint: disable=super-init-not-called self.dispatcher = srv.dispatcher self.name = name self.log = logger - HasProperties.__init__(self, supercall_init=False) + # do not call HasProperties.__init__, as this will supercall ThreadingTCPServer + self.initProperties() bindto = options.pop('bindto', 'localhost') bindport = int(options.pop('bindport', DEF_PORT)) detailed_errors = options.pop('detailed_errors', False) diff --git a/test/test_datatypes.py b/test/test_datatypes.py index 5002fd5..c324a5a 100644 --- a/test/test_datatypes.py +++ b/test/test_datatypes.py @@ -26,7 +26,7 @@ import pytest from secop.datatypes import ArrayOf, BLOBType, BoolType, \ - DataType, EnumType, FloatRange, IntRange, ProgrammingError, \ + DataType, EnumType, FloatRange, IntRange, ProgrammingError, ConfigError, \ ScaledInteger, StringType, TextType, StructOf, TupleOf, get_datatype, CommandType @@ -63,11 +63,14 @@ def test_FloatRange(): dt(13.14 - 10) # raises an error, if resolution is not handled correctly assert dt.export_value(-2.718) == -2.718 assert dt.import_value(-2.718) == -2.718 - with pytest.raises(ValueError): + with pytest.raises(ProgrammingError): FloatRange('x', 'Y') # check that unit can be changed - dt.unit = 'K' + dt.setProperty('unit', 'K') assert dt.export_datatype() == {'type': 'double', 'min':-3.14, 'max':3.14, 'unit': 'K'} + with pytest.raises(KeyError): + dt.setProperty('visibility', 0) + dt.setProperty('absolute_resolution', 0) dt = FloatRange() copytest(dt) @@ -84,6 +87,14 @@ def test_FloatRange(): assert dt.format_value(3.14, '') == '3.14' assert dt.format_value(3.14, '#') == '3.14 #' + dt.setProperty('min', 1) + dt.setProperty('max', 0) + with pytest.raises(ConfigError): + dt.checkProperties() + + with pytest.raises(ProgrammingError): + FloatRange(resolution=1) + def test_IntRange(): dt = IntRange(-3, 3) @@ -100,7 +111,7 @@ def test_IntRange(): dt([19, 'X']) dt(1) dt(0) - with pytest.raises(ValueError): + with pytest.raises(ProgrammingError): IntRange('xc', 'Yx') dt = IntRange() @@ -110,6 +121,11 @@ def test_IntRange(): assert dt.export_datatype() == {'type': 'int', 'max': 16777216,'min': -16777216} assert dt.format_value(42) == '42' + dt.setProperty('min', 1) + dt.setProperty('max', 0) + with pytest.raises(ConfigError): + dt.checkProperties() + def test_ScaledInteger(): dt = ScaledInteger(0.01, -3, 3) copytest(dt) @@ -128,18 +144,24 @@ def test_ScaledInteger(): dt(0) with pytest.raises(ValueError): ScaledInteger('xc', 'Yx') - with pytest.raises(ValueError): + with pytest.raises(ProgrammingError): ScaledInteger(scale=0, minval=1, maxval=2) - with pytest.raises(ValueError): + with pytest.raises(ProgrammingError): ScaledInteger(scale=-10, minval=1, maxval=2) # check that unit can be changed - dt.unit = 'A' - assert dt.export_datatype() == {'type': 'scaled', 'scale':0.01, 'min':-300, 'max':300, 'unit': 'A'} + dt.setProperty('unit', 'A') + assert dt.export_datatype() == {'type': 'scaled', 'scale':0.01, 'min':-300, 'max':300, + 'unit': 'A'} assert dt.export_value(0.0001) == int(0) assert dt.export_value(2.71819) == int(272) assert dt.import_value(272) == 2.72 + dt.setProperty('scale', 0.1) + assert dt.export_datatype() == {'type': 'scaled', 'scale':0.1, 'min':-30, 'max':30, + 'unit':'A'} + assert dt.absolute_resolution == dt.scale + dt = ScaledInteger(0.003, 0, 1, unit='X', fmtstr='%.1f', absolute_resolution=0.001, relative_resolution=1e-5) copytest(dt) @@ -155,6 +177,14 @@ def test_ScaledInteger(): with pytest.raises(ValueError): dt(1.004) + dt.setProperty('min', 1) + dt.setProperty('max', 0) + with pytest.raises(ConfigError): + dt.checkProperties() + + with pytest.raises(ValueError): + dt.setProperty('scale', None) + def test_EnumType(): # test constructor catching illegal arguments @@ -219,6 +249,11 @@ def test_BLOBType(): dt('abcd') assert dt(b'abcd') == b'abcd' + dt.setProperty('minbytes', 1) + dt.setProperty('maxbytes', 0) + with pytest.raises(ConfigError): + dt.checkProperties() + assert dt.export_value(b'abcd') == 'YWJjZA==' assert dt.export_value(b'abcd') == 'YWJjZA==' # assert dt.export_value('abcd') == 'YWJjZA==' @@ -260,6 +295,11 @@ def test_StringType(): assert dt.format_value('abcd') == "'abcd'" + dt.setProperty('minchars', 1) + dt.setProperty('maxchars', 0) + with pytest.raises(ConfigError): + dt.checkProperties() + def test_TextType(): # test constructor catching illegal arguments @@ -309,6 +349,9 @@ def test_BoolType(): assert dt.format_value(0) == "False" assert dt.format_value(True) == "True" + with pytest.raises(TypeError): + # pylint: disable=unexpected-keyword-arg + BoolType(unit='K') def test_ArrayOf(): # test constructor catching illegal arguments @@ -341,6 +384,17 @@ def test_ArrayOf(): assert dt.format_value([1,2,3], '') == '[1, 2, 3]' assert dt.format_value([1,2,3], 'Q') == '[1, 2, 3] Q' + dt = ArrayOf(FloatRange(unit='K')) + assert dt.members.unit == 'K' + dt.setProperty('unit', 'mm') + with pytest.raises(TypeError): + # pylint: disable=unexpected-keyword-arg + ArrayOf(BoolType(), unit='K') + + dt.setProperty('minlen', 1) + dt.setProperty('maxlen', 0) + with pytest.raises(ConfigError): + dt.checkProperties() def test_TupleOf(): # test constructor catching illegal arguments diff --git a/test/test_modules.py b/test/test_modules.py index ef1e9d0..0595c63 100644 --- a/test/test_modules.py +++ b/test/test_modules.py @@ -31,7 +31,6 @@ from secop.modules import Communicator, Drivable, Module from secop.params import Command, Override, Parameter - def test_Communicator(): logger = type('LoggerStub', (object,), dict( debug = lambda self, *a: print(*a), @@ -54,11 +53,12 @@ def test_Communicator(): assert event.is_set() # event should be set immediately def test_ModuleMeta(): + # pylint: disable=too-many-function-args newclass1 = ModuleMeta.__new__(ModuleMeta, 'TestDrivable', (Drivable,), { "parameters" : { 'pollinterval': Override(reorder=True), 'param1' : Parameter('param1', datatype=BoolType(), default=False), - 'param2': Parameter('param2', datatype=BoolType(), default=True), + 'param2': Parameter('param2', datatype=FloatRange(unit='Ohm'), default=True), "cmd": Command('stuff', argument=BoolType(), result=BoolType()) }, "commands": { @@ -82,10 +82,12 @@ def test_ModuleMeta(): sortcheck1 = ['value', 'status', 'target', 'pollinterval', 'param1', 'param2', 'cmd', 'a1', 'a2', 'cmd2'] + # pylint: disable=too-many-function-args newclass2 = ModuleMeta.__new__(ModuleMeta, 'UpperClass', (newclass1,), { "parameters": { 'cmd2': Override('another stuff'), - 'a1': Override(datatype=FloatRange(), reorder=True), + 'value': Override(datatype=FloatRange(unit='deg'), reorder=True), + 'a1': Override(datatype=FloatRange(unit='$/s'), reorder=True), 'b2': Parameter('a2', datatype=BoolType(), default=True), }, }) @@ -125,6 +127,25 @@ def test_ModuleMeta(): # HACK: atm. disabled to fix all other problems first. assert check_order + sorted(check_order) + o1 = newclass1('o1', logger, {'.description':''}, srv) + o2 = newclass2('o2', logger, {'.description':''}, srv) + assert o2.parameters['a1'].datatype.unit == 'deg/s' + o2 = newclass2('o2', logger, {'.description':'', 'value.unit':'mm', 'param2.unit':'mm'}, srv) + # check datatype is not shared + assert o1.parameters['param2'].datatype.unit == 'Ohm' + assert o2.parameters['param2'].datatype.unit == 'mm' + # check '$' in unit works properly + assert o2.parameters['a1'].datatype.unit == 'mm/s' + cfg = newclass2.configurables + assert set(cfg.keys()) == {'export', 'group', 'description', + 'meaning', 'visibility', 'implementation', 'interface_class', 'target', 'stop', + 'status', 'param1', 'param2', 'cmd', 'a2', 'pollinterval', 'b2', 'cmd2', 'value', + 'a1'} + assert set(cfg['value'].keys()) == {'group', 'export', 'relative_resolution', + 'visibility', 'unit', 'default', 'optional', 'datatype', 'fmtstr', + 'absolute_resolution', 'poll', 'max', 'min', 'readonly', 'constant', + 'description'} + # check on the level of classes # this checks newclass1 too, as it is inherited by newclass2 for baseclass in newclass2.__mro__: diff --git a/test/test_properties.py b/test/test_properties.py index 7012c9b..436bbd7 100644 --- a/test/test_properties.py +++ b/test/test_properties.py @@ -24,10 +24,10 @@ import pytest from secop.datatypes import IntRange, StringType, FloatRange, ValueType -from secop.errors import ProgrammingError +from secop.errors import ProgrammingError, ConfigError from secop.properties import Property, Properties, HasProperties - +# args are: datatype, default, extname, export, mandatory, settable V_test_Property = [ [(StringType(), 'default', 'extname', False, False), dict(default='default', extname='extname', export=True, mandatory=False)], @@ -45,16 +45,16 @@ V_test_Property = [ dict(default=0, extname='', export=False, mandatory=True)], [(IntRange(), 0, '', False, False), dict(default=0, extname='', export=False, mandatory=False)], - [(IntRange(), None, '', False, False), - dict(default=0, extname='', export=False, mandatory=True)], # 'normal types + no default -> mandatory - [(ValueType(), None, '', False, False), - dict(default=None, extname='', export=False, mandatory=False)], # 'special type + no default -> NOT mandatory + [(IntRange(), None, '', None), + dict(default=0, extname='', export=False, mandatory=True)], # mandatory not given, no default -> mandatory + [(ValueType(), 1, '', False), + dict(default=1, extname='', export=False, mandatory=False)], # mandatory not given, default given -> NOT mandatory ] @pytest.mark.parametrize('args, check', V_test_Property) def test_Property(args, check): p = Property('', *args) - for k,v in check.items(): - assert getattr(p, k) == v + result = {k: getattr(p, k) for k in check} + assert result == check def test_Property_basic(): with pytest.raises(TypeError): @@ -96,9 +96,24 @@ class cl(c): properties = { 'a' : Property('', IntRange(), 3), 'b' : Property('', FloatRange(), 3.14), + 'minabc': Property('', IntRange(), 8), + 'maxabc': Property('', IntRange(), 9), + 'minx': Property('', IntRange(), 2), + 'maxy': Property('', IntRange(), 1), } def test_HasProperties(): + o = c() + assert o.properties['a'] == 1 o = cl() assert o.properties['a'] == 3 assert o.properties['b'] == 3.14 + +def test_Property_checks(): + o = c() + o.checkProperties() + o = cl() + o.checkProperties() + o.setProperty('maxabc', 1) + with pytest.raises(ConfigError): + o.checkProperties()