From f8d8cbd76df30f9f94161c17cb94d784bf009ede Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Fri, 4 Oct 2024 16:03:33 +0200 Subject: [PATCH] add Datatype.to_string as counterpart of .from_string This allows simple UIs using stringified versions in text input. - add frappy.client.SecopClient.setParameterFromString - fix datatype tests + add updateItem to CallbackObject (for doc) Change-Id: Ic7792bdc51ba0884637b2d4acc0e9433c669314d Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/34736 Tested-by: Jenkins Automated Tests Reviewed-by: Markus Zolliker --- frappy/client/__init__.py | 50 +++++++++-- frappy/datatypes.py | 182 ++++++++++++++++++++------------------ test/test_datatypes.py | 14 +-- 3 files changed, 143 insertions(+), 103 deletions(-) diff --git a/frappy/client/__init__.py b/frappy/client/__init__.py index 198844e..0fb720f 100644 --- a/frappy/client/__init__.py +++ b/frappy/client/__init__.py @@ -22,6 +22,8 @@ # ***************************************************************************** """general SECoP client""" +# pylint: disable=too-many-positional-arguments + import json import queue import re @@ -79,10 +81,12 @@ class CallbackObject: this is mainly for documentation, but it might be extended and used as a mixin for objects registered as a callback """ - def updateEvent(self, module, parameter, value, timestamp, readerror): + def updateItem(self, module, parameter, item): """called whenever a value is changed - or when new callbacks are registered + :param module: the module name + :param parameter: the parameter name + :param item: a CacheItem object """ def unhandledMessage(self, action, ident, data): @@ -108,6 +112,12 @@ class CallbackObject: and on every changed module with module== """ + def updateEvent(self, module, parameter, value, timestamp, readerror): + """legacy method: called whenever a value is changed + + or when new callbacks are registered + """ + class CacheItem(tuple): """cache entry @@ -115,12 +125,14 @@ class CacheItem(tuple): includes formatting information inheriting from tuple: compatible with old previous version of cache """ + def __new__(cls, value, timestamp=None, readerror=None, datatype=None): obj = tuple.__new__(cls, (value, timestamp, readerror)) if datatype: try: - # override default method + # override default methods obj.format_value = datatype.format_value + obj.to_string = datatype.to_string except AttributeError: pass return obj @@ -138,29 +150,40 @@ class CacheItem(tuple): return self[2] def __str__(self): - """format value without unit""" + """format value without unit + + may be used in this form for SecopClient.setParameterFromString + """ if self[2]: # readerror return repr(self[2]) - return self.format_value(self[0], unit='') # skip unit + return self.to_string(self[0]) def formatted(self): - """format value with using unit""" + """format value with using unit + + nicer format for humans, hard to parse + """ if self[2]: # readerror return repr(self[2]) return self.format_value(self[0]) @staticmethod - def format_value(value, unit=None): + def format_value(value): """typically overridden with datatype.format_value""" return str(value) + @staticmethod + def to_string(value): + """typically overridden with datatype.to_string""" + return str(value) + def __repr__(self): args = (self.value,) if self.timestamp: args += (self.timestamp,) if self.readerror: args += (self.readerror,) - return f'CacheItem{repr(args)}' + return f'CacheItem{args!r}' class Cache(dict): @@ -703,6 +726,17 @@ class SecopClient(ProxyClient): self.request(WRITEREQUEST, self.identifier[module, parameter], value) return self.cache[module, parameter] + def setParameterFromString(self, module, parameter, formatted): + """set parameter from string + + formatted is a string in the form obtained by str() + """ + self.connect() # make sure we are connected + datatype = self.modules[module]['parameters'][parameter]['datatype'] + value = datatype.from_string(formatted) + self.request(WRITEREQUEST, self.identifier[module, parameter], value) + return self.cache[module, parameter] + def execCommand(self, module, command, argument=None): self.connect() # make sure we are connected datatype = self.modules[module]['commands'][command]['datatype'].argument diff --git a/frappy/datatypes.py b/frappy/datatypes.py index 8e9bd92..1bac6ee 100644 --- a/frappy/datatypes.py +++ b/frappy/datatypes.py @@ -25,13 +25,13 @@ import sys +import ast from base64 import b64decode, b64encode from frappy.errors import ConfigError, ProgrammingError, \ - ProtocolError, RangeError, WrongTypeError + RangeError, WrongTypeError from frappy.lib import clamp, generalConfig from frappy.lib.enum import Enum -from frappy.parse import Parser from frappy.properties import HasProperties, Property generalConfig.set_default('lazy_number_validation', False) @@ -41,8 +41,6 @@ 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() - def shortrepr(value): """shortened repr for error messages @@ -83,9 +81,29 @@ class DataType(HasProperties): return self(value) def from_string(self, text): - """interprets a given string and returns a validated (internal) value""" - # to evaluate values from configfiles, ui, etc... - raise NotImplementedError + """interprets a given string and returns a validated (internal) value + + intended to be given e.g. from a GUI text input + """ + try: + value = ast.literal_eval(text) + except Exception: + raise WrongTypeError(f'{shortrepr(text)} is no valid value') from None + return self(value) + + def to_string(self, value): + """convert a value of this type into a string + + This is intended for a GUI text input and is the opposite of + :meth:`from_string` + - no units are shown + - value is not checked before formatting + + typically the output is a stringified value in python syntax except for + - StringType: the bare string is returned + - EnumType: the name of the enum is returned + """ + return self.format_value(value, False) def export_datatype(self): """return a python object which after jsonifying identifies this datatype""" @@ -94,7 +112,6 @@ class DataType(HasProperties): f"It is intended for internal use only." ) - def export_value(self, value): """if needed, reformat value for transport""" return value @@ -107,12 +124,17 @@ class DataType(HasProperties): """ return self(value) - def format_value(self, value, unit=None): - """format a value of this type into a str string + def format_value(self, value, unit=True): + """format a value of this type into a string This is intended for 'nice' formatting for humans and is NOT the opposite of :meth:`from_string` - if unit is given, use it, else use the unit of the datatype (if any)""" + + possible values of unit: + - True: use the string of the datatype + - False: return a value interpretable by ast.literal_eval (internal use only) + - any other string: use as unit (internal use only) + """ raise NotImplementedError def set_properties(self, **kwds): @@ -259,15 +281,11 @@ class FloatRange(HasUnit, DataType): """returns a python object fit for serialisation""" return float(value) - def from_string(self, text): - value = float(text) - return self(value) - - def format_value(self, value, unit=None): - if unit is None: + def format_value(self, value, unit=True): + if unit is True: unit = self.unit if unit: - return ' '.join([self.fmtstr % value, unit]) + return f'{self.fmtstr % value} {unit}' return self.fmtstr % value def compatible(self, other): @@ -337,11 +355,7 @@ class IntRange(DataType): """returns a python object fit for serialisation""" return int(value) - def from_string(self, text): - value = int(text) - return self(value) - - def format_value(self, value, unit=None): + def format_value(self, value, unit=True): return f'{value}' def compatible(self, other): @@ -458,15 +472,11 @@ class ScaledInteger(HasUnit, DataType): except Exception: raise WrongTypeError(f'can not import {shortrepr(value)} to scaled') from None - def from_string(self, text): - value = float(text) - return self(value) - - def format_value(self, value, unit=None): - if unit is None: + def format_value(self, value, unit=True): + if unit is True: unit = self.unit if unit: - return ' '.join([self.fmtstr % value, unit]) + return f'{self.fmtstr % value} {unit}' return self.fmtstr % value def compatible(self, other): @@ -483,6 +493,7 @@ class EnumType(DataType): :param members: members dict or None when using kwds only :param kwds: (additional) members """ + def __init__(self, enum_or_name='', members=None, **kwds): super().__init__() if members is not None: @@ -518,10 +529,19 @@ class EnumType(DataType): raise WrongTypeError(f'{shortrepr(value)} must be either int or str for an enum value') from None def from_string(self, text): - return self(text) + try: + return self._enum(text.strip()) + except KeyError: + return super().from_string(text) - def format_value(self, value, unit=None): - return f'{self._enum[value].name}<{self._enum[value].value}>' + def format_value(self, value, unit=True): + if unit is False: + return repr(value.name) + # for humans: contains more information (name + code) + return f'{value.name}<{value.value}>' + + def to_string(self, value): + return value.name def set_name(self, name): self._enum.name = name @@ -584,12 +604,7 @@ class BLOBType(DataType): except Exception: raise WrongTypeError(f'can not b64decode {shortrepr(value)}') from None - def from_string(self, text): - value = text - # XXX: what should we do here? - return self(value) - - def format_value(self, value, unit=None): + def format_value(self, value, unit=True): return repr(value) def compatible(self, other): @@ -653,13 +668,15 @@ class StringType(DataType): """returns a python object fit for serialisation""" return f'{value}' - def from_string(self, text): - value = str(text) - return self(value) - - def format_value(self, value, unit=None): + def format_value(self, value, unit=True): return repr(value) + def to_string(self, value): + return value + + def from_string(self, text): + return self(text) + def compatible(self, other): try: if self.minchars < other.minchars or self.maxchars > other.maxchars or \ @@ -700,25 +717,26 @@ class BoolType(DataType): def __repr__(self): return 'BoolType()' - def __call__(self, value): + def from_string(self, text): """accepts 0, False, 1, True""" - # TODO: probably remove conversion from string (not needed anymore with python cfg) - if value in [0, '0', 'False', 'false', 'no', 'off', False]: + value = text.strip() + if value in ['0', 'False', 'false', 'no', 'off']: return False - if value in [1, '1', 'True', 'true', 'yes', 'on', True]: + if value in ['1', 'True', 'true', 'yes', 'on']: return True raise WrongTypeError(f'{shortrepr(value)} is not a boolean value!') + def __call__(self, value): + if value in (0, 1): + return bool(value) + raise WrongTypeError(f'{shortrepr(value)} is not a boolean value!') + def export_value(self, value): """returns a python object fit for serialisation""" return self(value) - def from_string(self, text): - value = text - return self(value) - - def format_value(self, value, unit=None): - return repr(bool(value)) + def format_value(self, value, unit=True): + return repr(value) def compatible(self, other): other(False) @@ -754,6 +772,10 @@ class ArrayOf(DataType): self.members = members self.set_properties(minlen=minlen, maxlen=maxlen) + @property + def unit(self): + return self.members.unit + def copy(self): """DataType.copy does not work when members are enums""" return ArrayOf(self.members.copy(), self.minlen, self.maxlen) @@ -823,25 +845,20 @@ class ArrayOf(DataType): """returns a python object from serialisation""" return tuple(self.members.import_value(elem) for elem in value) - def from_string(self, text): - value, rem = Parser.parse(text) - if rem: - raise ProtocolError(f'trailing garbage: {rem!r}') - return self(value) - - def format_value(self, value, unit=None): - innerunit = '' - if unit is None: + def format_value(self, value, unit=True): + innerunit = False + if unit is True: members = self.members while isinstance(members, ArrayOf): members = members.members if members.unit: unit = members.unit else: - innerunit = None + unit = '' + innerunit = True res = f"[{', '.join([self.members.format_value(elem, innerunit) for elem in value])}]" if unit: - return ' '.join([res, unit]) + return f'{res} {unit}' return res def compatible(self, other): @@ -918,14 +935,8 @@ class TupleOf(DataType): """returns a python object from serialisation""" return tuple(sub.import_value(elem) for sub, elem in zip(self.members, value)) - def from_string(self, text): - value, rem = Parser.parse(text) - if rem: - raise ProtocolError(f'trailing garbage: {rem!r}') - return self(value) - - def format_value(self, value, unit=None): - return f"({', '.join([sub.format_value(elem) for sub, elem in zip(self.members, value)])})" + def format_value(self, value, unit=True): + return f"({', '.join([sub.format_value(elem, unit) for sub, elem in zip(self.members, value)])})" def compatible(self, other): if not isinstance(other, TupleOf): @@ -1036,14 +1047,13 @@ class StructOf(DataType): return {str(k): self.members[k].import_value(v) for k, v in value.items()} - def from_string(self, text): - value, rem = Parser.parse(text) - if rem: - raise ProtocolError(f'trailing garbage: {rem!r}') - return self(dict(value)) - - def format_value(self, value, unit=None): - return '{%s}' % (', '.join(['%s=%s' % (k, self.members[k].format_value(v)) for k, v in value.items()])) + def format_value(self, value, unit=True): + if unit is False: + return '{%s}' % (', '.join(['%r: %s' % (k, self.members[k].format_value(v, False)) + for k, v in value.items()])) + # more human readable format (no quotes on keys) + return '{%s}' % (', '.join(['%s=%s' % (k, self.members[k].format_value(v, True)) + for k, v in value.items()])) def compatible(self, other): try: @@ -1103,14 +1113,11 @@ class CommandType(DataType): raise ProgrammingError('values of type command can not be transported!') def from_string(self, text): - value, rem = Parser.parse(text) - if rem: - raise ProtocolError(f'trailing garbage: {rem!r}') - return self(value) + raise ProgrammingError('a string can not be converted to a command') - def format_value(self, value, unit=None): + def format_value(self, value, unit=True): # actually I have no idea what to do here! - raise NotImplementedError + raise ProgrammingError('commands can not be converted to a string') def compatible(self, other): try: @@ -1221,7 +1228,6 @@ class OrType(DataType): self.types = types self.default = self.types[0].default - def __call__(self, value): """accepts any of the given types, takes the first valid""" for t in self.types: diff --git a/test/test_datatypes.py b/test/test_datatypes.py index c7dd5b0..c0442d7 100644 --- a/test/test_datatypes.py +++ b/test/test_datatypes.py @@ -264,7 +264,7 @@ def test_EnumType(): with pytest.raises(RangeError): dt.import_value('A') - assert dt.format_value(3) == 'a<3>' + assert dt.format_value(dt(3)) == 'a<3>' def test_BLOBType(): @@ -378,20 +378,20 @@ def test_BoolType(): with pytest.raises(WrongTypeError): dt('av') - assert dt('true') is True - assert dt('off') is False + assert dt.from_string('true') is True + assert dt.from_string('off') is False assert dt(1) is True - assert dt.export_value('false') is False + assert dt.export_value(False) is False assert dt.export_value(0) is False - assert dt.export_value('on') is True + assert dt.export_value(1) is True assert dt.import_value(False) is False assert dt.import_value(True) is True with pytest.raises(WrongTypeError): dt.import_value('av') - assert dt.format_value(0) == "False" + assert dt.format_value(dt(0)) == "False" assert dt.format_value(True) == "True" with pytest.raises(TypeError): @@ -468,7 +468,7 @@ def test_TupleOf(): assert dt.export_value([1, True]) == [1, True] assert dt.import_value([1, True]) == (1, True) - assert dt.format_value([3,0]) == "(3, False)" + assert dt.format_value(dt([3,0])) == "(3, False)" dt = TupleOf(EnumType('myenum', single=0)) copytest(dt)