core: alternative approach for optional accessibles

This is meant to replace change 33375.
Optional commands and parameters may be declared with the argument
optional=True. In principle, optional commands are not really needed
to be declared, but doing so is nice for documentation reasons
and for inherited accessible properties.

Optional parameters and commands can not be used and are not
exported als long as they are not overridden in subclasses.

- add a test for this
+ fix an issue with checking for methods like read_<param> without
  <param> being a parameter

Change-Id: Ide5021127a02778e7f2f7162555ec8826f1471cb
Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/35495
Tested-by: Jenkins Automated Tests <pedersen+jenkins@frm2.tum.de>
Reviewed-by: Alexander Zaft <a.zaft@fz-juelich.de>
Reviewed-by: Markus Zolliker <markus.zolliker@psi.ch>
Reviewed-by: Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
This commit is contained in:
zolliker 2025-01-27 14:11:36 +01:00
parent 8c2588a5ed
commit 3663c62b46
4 changed files with 130 additions and 15 deletions

View File

@ -60,13 +60,14 @@ class HasAccessibles(HasProperties):
(so the dispatcher will get notified of changed values)
"""
isWrapped = False
checkedMethods = set()
checkedMethods = ()
@classmethod
def __init_subclass__(cls): # pylint: disable=too-many-branches
super().__init_subclass__()
if cls.isWrapped:
return
cls.checkedMethods = set(cls.checkedMethods) # might be initial empty tuple
# merge accessibles from all sub-classes, treat overrides
# for now, allow to use also the old syntax (parameters/commands dict)
accessibles = OrderedDict() # dict of accessibles
@ -114,8 +115,8 @@ class HasAccessibles(HasProperties):
wrapped_name = '_' + cls.__name__
for pname, pobj in accessibles.items():
# wrap of reading/writing funcs
if not isinstance(pobj, Parameter):
# nothing to do for Commands
if not isinstance(pobj, Parameter) or pobj.optional:
# nothing to do for Commands and optional parameters
continue
rname = 'read_' + pname
@ -202,7 +203,7 @@ class HasAccessibles(HasProperties):
cls.checkedMethods.update(cls.wrappedAttributes)
# check for programming errors
for attrname in dir(cls):
for attrname in cls.__dict__:
prefix, _, pname = attrname.partition('_')
if not pname:
continue
@ -390,6 +391,8 @@ class Module(HasAccessibles):
accessibles = self.accessibles
self.accessibles = {}
for aname, aobj in accessibles.items():
if aobj.optional:
continue
# make a copy of the Parameter/Command object
aobj = aobj.copy()
acfg = cfgdict.pop(aname, None)

View File

@ -47,6 +47,7 @@ class Accessible(HasProperties):
"""
ownProperties = None
optional = False
def init(self, kwds):
# do not use self.propertyValues.update here, as no invalid values should be
@ -96,6 +97,8 @@ class Accessible(HasProperties):
props = []
for k, v in sorted(self.propertyValues.items()):
props.append(f'{k}={v!r}')
if self.optional:
props.append('optional=True')
return f"{self.__class__.__name__}({', '.join(props)})"
def fixExport(self):
@ -191,8 +194,9 @@ class Parameter(Accessible):
readerror = None
omit_unchanged_within = 0
def __init__(self, description=None, datatype=None, inherit=True, **kwds):
def __init__(self, description=None, datatype=None, inherit=True, optional=False, **kwds):
super().__init__()
self.optional = optional
if 'poll' in kwds and generalConfig.tolerate_poll_property:
kwds.pop('poll')
if datatype is None:
@ -226,10 +230,16 @@ class Parameter(Accessible):
def __get__(self, instance, owner):
if instance is None:
return self
return instance.parameters[self.name].value
try:
return instance.parameters[self.name].value
except KeyError:
raise ProgrammingError(f'optional parameter {self.name} it is not implemented') from None
def __set__(self, obj, value):
obj.announceUpdate(self.name, value)
try:
obj.announceUpdate(self.name, value)
except KeyError:
raise ProgrammingError(f'optional parameter {self.name} it is not implemented') from None
def __set_name__(self, owner, name):
self.name = name
@ -366,9 +376,6 @@ class Command(Accessible):
* True: exported, name automatic.
* a string: exported with custom name''', OrType(BoolType(), StringType()),
export=False, default=True)
# optional = Property(
# '[internal] is the command optional to implement? (vs. mandatory)', BoolType(),
# export=False, default=False, settable=False)
datatype = Property(
"datatype of the command, auto generated from 'argument' and 'result'",
DataTypeType(), extname='datainfo', export='always')
@ -384,8 +391,9 @@ class Command(Accessible):
func = None
def __init__(self, argument=False, *, result=None, inherit=True, **kwds):
def __init__(self, argument=False, *, result=None, inherit=True, optional=False, **kwds):
super().__init__()
self.optional = optional
if 'datatype' in kwds:
# self.init will complain about invalid keywords except 'datatype', as this is a property
raise ProgrammingError("Command() got an invalid keyword 'datatype'")
@ -411,8 +419,8 @@ class Command(Accessible):
def __set_name__(self, owner, name):
self.name = name
if self.func is None:
raise ProgrammingError(f'Command {owner.__name__}.{name} must be used as a method decorator')
if self.func is None and not self.optional:
raise ProgrammingError(f'Command {owner.__name__}.{name} must be optional or used as a method decorator')
self.fixExport()
self.datatype = CommandType(self.argument, self.result)

View File

@ -102,7 +102,9 @@ class Handler:
"""create the wrapped read_* or write_* methods"""
# at this point, this 'method_names' entry is no longer used -> delete
self.method_names.discard((self.func.__module__, self.func.__qualname__))
owner.checkedMethods.add(name)
# avoid complain about read_<name> or write_<name> method without parameter <name>
# can not use .add() here, as owner.checkedMethods might be an empty tuple
owner.checkedMethods = set(owner.checkedMethods) | {name}
for key in self.keys:
wrapped = self.wrap(key)
method_name = self.prefix + key

View File

@ -26,7 +26,7 @@ import pytest
from frappy.datatypes import BoolType, FloatRange, IntRange, StructOf
from frappy.errors import ProgrammingError
from frappy.modulebase import HasAccessibles
from frappy.modulebase import HasAccessibles, Module
from frappy.params import Command, Parameter
@ -149,3 +149,105 @@ def test_update_unchanged_ok(arg, value):
def test_update_unchanged_fail(arg):
with pytest.raises(ProgrammingError):
Parameter('', datatype=FloatRange(), default=0, update_unchanged=arg)
def make_module(cls):
class DispatcherStub:
def announce_update(self, moduleobj, pobj):
pass
class LoggerStub:
def debug(self, fmt, *args):
print(fmt % args)
info = warning = exception = error = debug
handlers = []
class ServerStub:
dispatcher = DispatcherStub()
secnode = None
return cls('test', LoggerStub(), {'description': 'test'}, ServerStub())
def test_optional_parameters():
class Base(Module):
p1 = Parameter('overridden', datatype=FloatRange(),
default=1, readonly=False, optional=True)
p2 = Parameter('not overridden', datatype=FloatRange(),
default=2, readonly=False, optional=True)
class Mod(Base):
p1 = Parameter()
def read_p1(self):
return self.p1
def write_p1(self, value):
return value
assert Base.accessibles['p2'].optional
with pytest.raises(ProgrammingError):
class Mod2(Base): # pylint: disable=unused-variable
def read_p2(self):
pass
with pytest.raises(ProgrammingError):
class Mod3(Base): # pylint: disable=unused-variable
def write_p2(self):
pass
base = make_module(Base)
mod = make_module(Mod)
assert 'p1' not in base.accessibles
assert 'p1' not in base.parameters
assert 'p2' not in base.accessibles
assert 'p2' not in base.parameters
assert 'p1' in mod.accessibles
assert 'p1' in mod.parameters
assert 'p2' not in mod.accessibles
assert 'p2' not in mod.parameters
assert mod.p1 == 1
assert mod.read_p1() == 1
mod.p1 = 11
assert mod.read_p1() == 11
with pytest.raises(ProgrammingError):
assert mod.p2
with pytest.raises(AttributeError):
mod.read_p2()
with pytest.raises(ProgrammingError):
mod.p2 = 2
with pytest.raises(AttributeError):
mod.write_p2(2)
def test_optional_commands():
class Base(Module):
c1 = Command(FloatRange(1), result=FloatRange(2), description='overridden', optional=True)
c2 = Command(description='not overridden', optional=True)
class Mod(Base):
def c1(self, value):
return value + 1
base = make_module(Base)
mod = make_module(Mod)
assert 'c1' not in base.accessibles
assert 'c1' not in base.commands
assert 'c2' not in base.accessibles
assert 'c2' not in base.commands
assert 'c1' in mod.accessibles
assert 'c1' in mod.commands
assert 'c2' not in mod.accessibles
assert 'c2' not in mod.commands
assert mod.c1(7) == 8
with pytest.raises(ProgrammingError):
mod.c2()