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 <pedersen+jenkins@frm2.tum.de>
Reviewed-by: Markus Zolliker <markus.zolliker@psi.ch>
This commit is contained in:
zolliker 2024-10-04 16:03:33 +02:00
parent f9f713811e
commit f8d8cbd76d
3 changed files with 143 additions and 103 deletions

View File

@ -22,6 +22,8 @@
# ***************************************************************************** # *****************************************************************************
"""general SECoP client""" """general SECoP client"""
# pylint: disable=too-many-positional-arguments
import json import json
import queue import queue
import re import re
@ -79,10 +81,12 @@ class CallbackObject:
this is mainly for documentation, but it might be extended this is mainly for documentation, but it might be extended
and used as a mixin for objects registered as a callback 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 """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): def unhandledMessage(self, action, ident, data):
@ -108,6 +112,12 @@ class CallbackObject:
and on every changed module with module==<module name> and on every changed module with module==<module name>
""" """
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): class CacheItem(tuple):
"""cache entry """cache entry
@ -115,12 +125,14 @@ class CacheItem(tuple):
includes formatting information includes formatting information
inheriting from tuple: compatible with old previous version of cache inheriting from tuple: compatible with old previous version of cache
""" """
def __new__(cls, value, timestamp=None, readerror=None, datatype=None): def __new__(cls, value, timestamp=None, readerror=None, datatype=None):
obj = tuple.__new__(cls, (value, timestamp, readerror)) obj = tuple.__new__(cls, (value, timestamp, readerror))
if datatype: if datatype:
try: try:
# override default method # override default methods
obj.format_value = datatype.format_value obj.format_value = datatype.format_value
obj.to_string = datatype.to_string
except AttributeError: except AttributeError:
pass pass
return obj return obj
@ -138,29 +150,40 @@ class CacheItem(tuple):
return self[2] return self[2]
def __str__(self): def __str__(self):
"""format value without unit""" """format value without unit
may be used in this form for SecopClient.setParameterFromString
"""
if self[2]: # readerror if self[2]: # readerror
return repr(self[2]) return repr(self[2])
return self.format_value(self[0], unit='') # skip unit return self.to_string(self[0])
def formatted(self): def formatted(self):
"""format value with using unit""" """format value with using unit
nicer format for humans, hard to parse
"""
if self[2]: # readerror if self[2]: # readerror
return repr(self[2]) return repr(self[2])
return self.format_value(self[0]) return self.format_value(self[0])
@staticmethod @staticmethod
def format_value(value, unit=None): def format_value(value):
"""typically overridden with datatype.format_value""" """typically overridden with datatype.format_value"""
return str(value) return str(value)
@staticmethod
def to_string(value):
"""typically overridden with datatype.to_string"""
return str(value)
def __repr__(self): def __repr__(self):
args = (self.value,) args = (self.value,)
if self.timestamp: if self.timestamp:
args += (self.timestamp,) args += (self.timestamp,)
if self.readerror: if self.readerror:
args += (self.readerror,) args += (self.readerror,)
return f'CacheItem{repr(args)}' return f'CacheItem{args!r}'
class Cache(dict): class Cache(dict):
@ -703,6 +726,17 @@ class SecopClient(ProxyClient):
self.request(WRITEREQUEST, self.identifier[module, parameter], value) self.request(WRITEREQUEST, self.identifier[module, parameter], value)
return self.cache[module, parameter] 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(<cache item>)
"""
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): def execCommand(self, module, command, argument=None):
self.connect() # make sure we are connected self.connect() # make sure we are connected
datatype = self.modules[module]['commands'][command]['datatype'].argument datatype = self.modules[module]['commands'][command]['datatype'].argument

View File

@ -25,13 +25,13 @@
import sys import sys
import ast
from base64 import b64decode, b64encode from base64 import b64decode, b64encode
from frappy.errors import ConfigError, ProgrammingError, \ from frappy.errors import ConfigError, ProgrammingError, \
ProtocolError, RangeError, WrongTypeError RangeError, WrongTypeError
from frappy.lib import clamp, generalConfig from frappy.lib import clamp, generalConfig
from frappy.lib.enum import Enum from frappy.lib.enum import Enum
from frappy.parse import Parser
from frappy.properties import HasProperties, Property from frappy.properties import HasProperties, Property
generalConfig.set_default('lazy_number_validation', False) generalConfig.set_default('lazy_number_validation', False)
@ -41,8 +41,6 @@ DEFAULT_MIN_INT = -16777216
DEFAULT_MAX_INT = 16777216 DEFAULT_MAX_INT = 16777216
UNLIMITED = 1 << 64 # internal limit for integers, is probably high enough for any datatype size UNLIMITED = 1 << 64 # internal limit for integers, is probably high enough for any datatype size
Parser = Parser()
def shortrepr(value): def shortrepr(value):
"""shortened repr for error messages """shortened repr for error messages
@ -83,9 +81,29 @@ class DataType(HasProperties):
return self(value) return self(value)
def from_string(self, text): def from_string(self, text):
"""interprets a given string and returns a validated (internal) value""" """interprets a given string and returns a validated (internal) value
# to evaluate values from configfiles, ui, etc...
raise NotImplementedError 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): def export_datatype(self):
"""return a python object which after jsonifying identifies this datatype""" """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." f"It is intended for internal use only."
) )
def export_value(self, value): def export_value(self, value):
"""if needed, reformat value for transport""" """if needed, reformat value for transport"""
return value return value
@ -107,12 +124,17 @@ class DataType(HasProperties):
""" """
return self(value) return self(value)
def format_value(self, value, unit=None): def format_value(self, value, unit=True):
"""format a value of this type into a str string """format a value of this type into a string
This is intended for 'nice' formatting for humans and is NOT This is intended for 'nice' formatting for humans and is NOT
the opposite of :meth:`from_string` 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 raise NotImplementedError
def set_properties(self, **kwds): def set_properties(self, **kwds):
@ -259,15 +281,11 @@ class FloatRange(HasUnit, DataType):
"""returns a python object fit for serialisation""" """returns a python object fit for serialisation"""
return float(value) return float(value)
def from_string(self, text): def format_value(self, value, unit=True):
value = float(text) if unit is True:
return self(value)
def format_value(self, value, unit=None):
if unit is None:
unit = self.unit unit = self.unit
if unit: if unit:
return ' '.join([self.fmtstr % value, unit]) return f'{self.fmtstr % value} {unit}'
return self.fmtstr % value return self.fmtstr % value
def compatible(self, other): def compatible(self, other):
@ -337,11 +355,7 @@ class IntRange(DataType):
"""returns a python object fit for serialisation""" """returns a python object fit for serialisation"""
return int(value) return int(value)
def from_string(self, text): def format_value(self, value, unit=True):
value = int(text)
return self(value)
def format_value(self, value, unit=None):
return f'{value}' return f'{value}'
def compatible(self, other): def compatible(self, other):
@ -458,15 +472,11 @@ class ScaledInteger(HasUnit, DataType):
except Exception: except Exception:
raise WrongTypeError(f'can not import {shortrepr(value)} to scaled') from None raise WrongTypeError(f'can not import {shortrepr(value)} to scaled') from None
def from_string(self, text): def format_value(self, value, unit=True):
value = float(text) if unit is True:
return self(value)
def format_value(self, value, unit=None):
if unit is None:
unit = self.unit unit = self.unit
if unit: if unit:
return ' '.join([self.fmtstr % value, unit]) return f'{self.fmtstr % value} {unit}'
return self.fmtstr % value return self.fmtstr % value
def compatible(self, other): def compatible(self, other):
@ -483,6 +493,7 @@ class EnumType(DataType):
:param members: members dict or None when using kwds only :param members: members dict or None when using kwds only
:param kwds: (additional) members :param kwds: (additional) members
""" """
def __init__(self, enum_or_name='', members=None, **kwds): def __init__(self, enum_or_name='', members=None, **kwds):
super().__init__() super().__init__()
if members is not None: 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 raise WrongTypeError(f'{shortrepr(value)} must be either int or str for an enum value') from None
def from_string(self, text): 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): def format_value(self, value, unit=True):
return f'{self._enum[value].name}<{self._enum[value].value}>' 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): def set_name(self, name):
self._enum.name = name self._enum.name = name
@ -584,12 +604,7 @@ class BLOBType(DataType):
except Exception: except Exception:
raise WrongTypeError(f'can not b64decode {shortrepr(value)}') from None raise WrongTypeError(f'can not b64decode {shortrepr(value)}') from None
def from_string(self, text): def format_value(self, value, unit=True):
value = text
# XXX: what should we do here?
return self(value)
def format_value(self, value, unit=None):
return repr(value) return repr(value)
def compatible(self, other): def compatible(self, other):
@ -653,13 +668,15 @@ class StringType(DataType):
"""returns a python object fit for serialisation""" """returns a python object fit for serialisation"""
return f'{value}' return f'{value}'
def from_string(self, text): def format_value(self, value, unit=True):
value = str(text)
return self(value)
def format_value(self, value, unit=None):
return repr(value) return repr(value)
def to_string(self, value):
return value
def from_string(self, text):
return self(text)
def compatible(self, other): def compatible(self, other):
try: try:
if self.minchars < other.minchars or self.maxchars > other.maxchars or \ if self.minchars < other.minchars or self.maxchars > other.maxchars or \
@ -700,25 +717,26 @@ class BoolType(DataType):
def __repr__(self): def __repr__(self):
return 'BoolType()' return 'BoolType()'
def __call__(self, value): def from_string(self, text):
"""accepts 0, False, 1, True""" """accepts 0, False, 1, True"""
# TODO: probably remove conversion from string (not needed anymore with python cfg) value = text.strip()
if value in [0, '0', 'False', 'false', 'no', 'off', False]: if value in ['0', 'False', 'false', 'no', 'off']:
return False return False
if value in [1, '1', 'True', 'true', 'yes', 'on', True]: if value in ['1', 'True', 'true', 'yes', 'on']:
return True return True
raise WrongTypeError(f'{shortrepr(value)} is not a boolean value!') 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): def export_value(self, value):
"""returns a python object fit for serialisation""" """returns a python object fit for serialisation"""
return self(value) return self(value)
def from_string(self, text): def format_value(self, value, unit=True):
value = text return repr(value)
return self(value)
def format_value(self, value, unit=None):
return repr(bool(value))
def compatible(self, other): def compatible(self, other):
other(False) other(False)
@ -754,6 +772,10 @@ class ArrayOf(DataType):
self.members = members self.members = members
self.set_properties(minlen=minlen, maxlen=maxlen) self.set_properties(minlen=minlen, maxlen=maxlen)
@property
def unit(self):
return self.members.unit
def copy(self): def copy(self):
"""DataType.copy does not work when members are enums""" """DataType.copy does not work when members are enums"""
return ArrayOf(self.members.copy(), self.minlen, self.maxlen) return ArrayOf(self.members.copy(), self.minlen, self.maxlen)
@ -823,25 +845,20 @@ class ArrayOf(DataType):
"""returns a python object from serialisation""" """returns a python object from serialisation"""
return tuple(self.members.import_value(elem) for elem in value) return tuple(self.members.import_value(elem) for elem in value)
def from_string(self, text): def format_value(self, value, unit=True):
value, rem = Parser.parse(text) innerunit = False
if rem: if unit is True:
raise ProtocolError(f'trailing garbage: {rem!r}')
return self(value)
def format_value(self, value, unit=None):
innerunit = ''
if unit is None:
members = self.members members = self.members
while isinstance(members, ArrayOf): while isinstance(members, ArrayOf):
members = members.members members = members.members
if members.unit: if members.unit:
unit = members.unit unit = members.unit
else: else:
innerunit = None unit = ''
innerunit = True
res = f"[{', '.join([self.members.format_value(elem, innerunit) for elem in value])}]" res = f"[{', '.join([self.members.format_value(elem, innerunit) for elem in value])}]"
if unit: if unit:
return ' '.join([res, unit]) return f'{res} {unit}'
return res return res
def compatible(self, other): def compatible(self, other):
@ -918,14 +935,8 @@ class TupleOf(DataType):
"""returns a python object from serialisation""" """returns a python object from serialisation"""
return tuple(sub.import_value(elem) for sub, elem in zip(self.members, value)) return tuple(sub.import_value(elem) for sub, elem in zip(self.members, value))
def from_string(self, text): def format_value(self, value, unit=True):
value, rem = Parser.parse(text) return f"({', '.join([sub.format_value(elem, unit) for sub, elem in zip(self.members, value)])})"
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 compatible(self, other): def compatible(self, other):
if not isinstance(other, TupleOf): if not isinstance(other, TupleOf):
@ -1036,14 +1047,13 @@ class StructOf(DataType):
return {str(k): self.members[k].import_value(v) return {str(k): self.members[k].import_value(v)
for k, v in value.items()} for k, v in value.items()}
def from_string(self, text): def format_value(self, value, unit=True):
value, rem = Parser.parse(text) if unit is False:
if rem: return '{%s}' % (', '.join(['%r: %s' % (k, self.members[k].format_value(v, False))
raise ProtocolError(f'trailing garbage: {rem!r}') for k, v in value.items()]))
return self(dict(value)) # more human readable format (no quotes on keys)
return '{%s}' % (', '.join(['%s=%s' % (k, self.members[k].format_value(v, True))
def format_value(self, value, unit=None): for k, v in value.items()]))
return '{%s}' % (', '.join(['%s=%s' % (k, self.members[k].format_value(v)) for k, v in value.items()]))
def compatible(self, other): def compatible(self, other):
try: try:
@ -1103,14 +1113,11 @@ class CommandType(DataType):
raise ProgrammingError('values of type command can not be transported!') raise ProgrammingError('values of type command can not be transported!')
def from_string(self, text): def from_string(self, text):
value, rem = Parser.parse(text) raise ProgrammingError('a string can not be converted to a command')
if rem:
raise ProtocolError(f'trailing garbage: {rem!r}')
return self(value)
def format_value(self, value, unit=None): def format_value(self, value, unit=True):
# actually I have no idea what to do here! # 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): def compatible(self, other):
try: try:
@ -1221,7 +1228,6 @@ class OrType(DataType):
self.types = types self.types = types
self.default = self.types[0].default self.default = self.types[0].default
def __call__(self, value): def __call__(self, value):
"""accepts any of the given types, takes the first valid""" """accepts any of the given types, takes the first valid"""
for t in self.types: for t in self.types:

View File

@ -264,7 +264,7 @@ def test_EnumType():
with pytest.raises(RangeError): with pytest.raises(RangeError):
dt.import_value('A') dt.import_value('A')
assert dt.format_value(3) == 'a<3>' assert dt.format_value(dt(3)) == 'a<3>'
def test_BLOBType(): def test_BLOBType():
@ -378,20 +378,20 @@ def test_BoolType():
with pytest.raises(WrongTypeError): with pytest.raises(WrongTypeError):
dt('av') dt('av')
assert dt('true') is True assert dt.from_string('true') is True
assert dt('off') is False assert dt.from_string('off') is False
assert dt(1) is True 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(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(False) is False
assert dt.import_value(True) is True assert dt.import_value(True) is True
with pytest.raises(WrongTypeError): with pytest.raises(WrongTypeError):
dt.import_value('av') dt.import_value('av')
assert dt.format_value(0) == "False" assert dt.format_value(dt(0)) == "False"
assert dt.format_value(True) == "True" assert dt.format_value(True) == "True"
with pytest.raises(TypeError): with pytest.raises(TypeError):
@ -468,7 +468,7 @@ def test_TupleOf():
assert dt.export_value([1, True]) == [1, True] assert dt.export_value([1, True]) == [1, True]
assert dt.import_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)) dt = TupleOf(EnumType('myenum', single=0))
copytest(dt) copytest(dt)