diff --git a/frappy/modulebase.py b/frappy/modulebase.py index 272a22d..7ac6741 100644 --- a/frappy/modulebase.py +++ b/frappy/modulebase.py @@ -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) diff --git a/frappy/params.py b/frappy/params.py index 64166ea..c649ab9 100644 --- a/frappy/params.py +++ b/frappy/params.py @@ -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) diff --git a/frappy/rwhandler.py b/frappy/rwhandler.py index 8ee7cda..4192547 100644 --- a/frappy/rwhandler.py +++ b/frappy/rwhandler.py @@ -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_ or write_ method without parameter + # 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 diff --git a/test/test_params.py b/test/test_params.py index 668edac..9af72c4 100644 --- a/test/test_params.py +++ b/test/test_params.py @@ -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()