diff --git a/doc/tutorial.rst b/doc/tutorial.rst index e1d97c0..1036b5b 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -238,22 +238,14 @@ To prepare a class for exporting on the Bus, provide the dbus introspection XML in a ''dbus'' class property or in its ''docstring''. For example:: from pydbus.generic import signal + from pydbus.strong_typing import typed_method, typed_property + from pydbus.xml_generator import interface, emits_changed_signal, attach_introspection_xml + @attach_introspection_xml + @interface("net.lew21.pydbus.TutorialExample") class Example(object): - """ - - - - - - - - - - - - """ + @typed_method(("s", ), "s") def EchoString(self, s): """returns whatever is passed to it""" return s @@ -261,7 +253,8 @@ in a ''dbus'' class property or in its ''docstring''. For example:: def __init__(self): self._someProperty = "initial value" - @property + @emits_changed_signal + @typed_property("s") def SomeProperty(self): return self._someProperty diff --git a/pydbus/generic.py b/pydbus/generic.py index abeb7ce..43d1298 100644 --- a/pydbus/generic.py +++ b/pydbus/generic.py @@ -76,10 +76,16 @@ class A: - they will be forwarded to all subscribed callbacks. """ - def __init__(self): + def __init__(self, method=None): + # if used as a decorator, method is defined + self.method = method self.map = {} - self.__qualname__ = "" # function uses ;) self.__doc__ = "Signal." + if method is None: + self.__qualname__ = "" # function uses ;) + else: + self.__qualname__ = "signal '{}'".format(method.__name__) + self.__name__ = method.__name__ def connect(self, object, callback): """Subscribe to the signal.""" diff --git a/pydbus/strong_typing.py b/pydbus/strong_typing.py new file mode 100644 index 0000000..c6f0d3a --- /dev/null +++ b/pydbus/strong_typing.py @@ -0,0 +1,36 @@ +"""Decorators for methods and properties to strongly typed the values.""" +import inspect + +from pydbus.xml_generator import get_arguments + + +def typed_property(value_type): + """ + Decorate a function as a dbus property getter. + + It alreay makes the method a property so another `@property` decorator may + not be used. + """ + def decorate(func): + func.prop_type = value_type + return property(func) + return decorate + + +def typed_method(argument_types, return_type): + """ + Decorate a function as a dbus method. + + Parameters + ---------- + argument_types : tuple + Required argument types for each argument except the first + return_type : string + Type of the returned value, must be None if it returns nothing + """ + def decorate(func): + func.arg_types = argument_types + func.ret_type = return_type + get_arguments(func) + return func + return decorate diff --git a/pydbus/tests/strong_typing.py b/pydbus/tests/strong_typing.py new file mode 100644 index 0000000..65a9b83 --- /dev/null +++ b/pydbus/tests/strong_typing.py @@ -0,0 +1,47 @@ +from pydbus.generic import signal +from pydbus.strong_typing import typed_method, typed_property + + +def test_signal(): + @signal + @typed_method(("s", ), None) + def dummy(self, parameter): + pass + + assert hasattr(dummy, 'method') + assert dummy.method.arg_types == ("s", ) + assert dummy.method.ret_type is None + + +def test_count_off(): + """Test what happens if to many or to few types are defined in methods.""" + try: + @typed_method(("s", "i", "o"), None) + def dummy(self, parameter): + pass + + assert False + except ValueError as e: + assert str(e) == "Number of argument types (3) differs from the number of parameters (1) in function 'dummy'" + + try: + @typed_method(("s", "i"), "o") + def dummy(self, parameter): + pass + + assert False + except ValueError as e: + assert str(e) == "Number of argument types (2) differs from the number of parameters (1) in function 'dummy'" + + try: + @typed_method(tuple(), None) + def dummy(self, parameter): + pass + + assert False + except ValueError as e: + assert str(e) == "Number of argument types (0) differs from the number of parameters (1) in function 'dummy'" + + +test_signal() +test_count_off() diff --git a/pydbus/tests/xml_generator.py b/pydbus/tests/xml_generator.py new file mode 100644 index 0000000..f99971f --- /dev/null +++ b/pydbus/tests/xml_generator.py @@ -0,0 +1,192 @@ +from sys import version_info + +from pydbus import xml_generator +from pydbus.generic import signal +from pydbus.strong_typing import typed_method, typed_property + + +@xml_generator.attach_introspection_xml +@xml_generator.interface("net.lvht.Foo1") +class Example(object): + + def __init__(self): + self._rw = 42 + + @typed_method(("s", ), "i") + def OneParamReturn(self, parameter): + return 42 + + @typed_method(("s", ), None) + def OneParamNoReturn(self, parameter): + pass + + @typed_property("i") + def ReadProperty(self): + return 42 + + @xml_generator.emits_changed_signal + @typed_property("i") + def RwProperty(self): + return self._rw + + @RwProperty.setter + def RwProperty(self, value): + self._rw = value + + +@xml_generator.attach_introspection_xml +@xml_generator.interface("net.lvht.Foolback") +class MultiInterface(object): + + def MethodFoolback(self): + pass + + @xml_generator.interface("net.lvht.Barface") + def MethodBarface(self): + pass + + @signal + def SignalFoolback(self): + pass + + @xml_generator.interface("net.lvht.Barface") + @signal + def SignalBarface(self): + pass + + +def test_get_arguments(): + def nothing(self): + pass + + def arguments(self, arg1, arg2): + pass + + def ctx_argument(self, arg, dbus_context): + pass + + @typed_method(tuple(), None) + def typed_nothing(self): + pass + + @typed_method(("s", "i"), None) + def typed_arguments(self, arg1, arg2): + pass + + assert xml_generator.get_arguments(nothing) == (tuple(), None) + assert xml_generator.get_arguments(arguments) == ((("arg1", None), ("arg2", None)), None) + assert xml_generator.get_arguments(ctx_argument) == ((("arg", None), ), None) + + assert xml_generator.get_arguments(typed_nothing) == (tuple(), None) + assert xml_generator.get_arguments(typed_arguments) == ((("arg1", "s"), ("arg2", "i")), None) + + +def test_valid(): + assert not hasattr(Example.OneParamReturn, "dbus_interface") + assert Example.OneParamReturn.arg_types == ("s", ) + assert Example.OneParamReturn.ret_type == "i" + + assert not hasattr(Example.OneParamNoReturn, "dbus_interface") + assert Example.OneParamNoReturn.arg_types == ("s", ) + assert Example.OneParamNoReturn.ret_type is None + + assert not hasattr(Example.ReadProperty, "dbus_interface") + assert isinstance(Example.ReadProperty, property) + assert Example.ReadProperty.fget.prop_type == "i" + assert Example.ReadProperty.fset is None + + assert not hasattr(Example.RwProperty, "dbus_interface") + assert isinstance(Example.RwProperty, property) + assert Example.RwProperty.fget.causes_signal is True + assert Example.RwProperty.fget.prop_type == "i" + assert Example.RwProperty.fset is not None + + assert Example.dbus == b'' + + +def test_multiple_interfaces(): + assert not hasattr(MultiInterface.MethodFoolback, "dbus_interface") + assert MultiInterface.MethodBarface.dbus_interface == "net.lvht.Barface" + assert not hasattr(MultiInterface.SignalFoolback, "dbus_interface") + assert MultiInterface.SignalBarface.dbus_interface == "net.lvht.Barface" + + assert MultiInterface.dbus == b'' + + +def test_invalid_function(): + """Test what happens if to many or to few types are defined in methods.""" + def Dummy(self, param=None): + pass + + try: + xml_generator.get_arguments(Dummy) + assert False + except ValueError as e: + assert str(e) == "Default values are not allowed for method 'Dummy'" + + if version_info[0] == 2: + E_NO_VARGS = ( + "Variable arguments arguments are not allowed for method 'Dummy'") + else: + E_NO_VARGS = E_NO_KWARGS = ( + "Variable arguments or keyword only arguments are not allowed for " + "method 'Dummy'") + + def Dummy(self, *vargs): + pass + + try: + xml_generator.get_arguments(Dummy) + assert False + except ValueError as e: + assert str(e) == E_NO_VARGS + + def Dummy(self, **kwargs): + pass + + try: + xml_generator.get_arguments(Dummy) + assert False + except ValueError as e: + assert str(e) == E_NO_VARGS + + +def test_require_strong_typing(): + try: + @xml_generator.attach_introspection_xml(True) + @xml_generator.interface("net.lvht.Foo1") + class Example(object): + + def Dummy(self, param): + pass + except ValueError as e: + assert str(e) == "No argument types defined for method 'Dummy'" + + @xml_generator.attach_introspection_xml(True) + @xml_generator.interface("net.lvht.Foo1") + class RequiredExample(object): + + @typed_method(("s", ), None) + def Dummy(self, param): + pass + + assert RequiredExample.Dummy.arg_types == ("s", ) + assert RequiredExample.Dummy.ret_type is None + + @xml_generator.attach_introspection_xml(False) + @xml_generator.interface("net.lvht.Foo1") + class OptionalExample(object): + + @typed_method(("s", ), None) + def Dummy(self, param): + pass + + assert OptionalExample.dbus == RequiredExample.dbus + assert OptionalExample is not RequiredExample + + +test_get_arguments() +test_valid() +test_multiple_interfaces() +test_invalid_function() +test_require_strong_typing() diff --git a/pydbus/xml_generator.py b/pydbus/xml_generator.py new file mode 100644 index 0000000..da31aa5 --- /dev/null +++ b/pydbus/xml_generator.py @@ -0,0 +1,265 @@ +"""Automatic XML documentation generator.""" +import inspect +import sys + +from itertools import islice +from xml.etree import ElementTree + +from pydbus.generic import signal + + +PROPERTY_EMITS_SIGNAL = "org.freedesktop.DBus.Property.EmitsChangedSignal" + + +# Python 2 treats them as methods, Python 3 as functions +ismethod = inspect.ismethod if sys.version_info[0] == 2 else inspect.isfunction + + +def extract_membered_types(function, require_strong_typing, arg_count): + has_arg_types = hasattr(function, "arg_types") + if has_arg_types: + arg_types = function.arg_types + elif require_strong_typing: + raise ValueError( + "No argument types defined for method " + "'{}'".format(function.__name__)) + else: + arg_types = (None, ) * arg_count + + if hasattr(function, "ret_type"): + if not has_arg_types: + raise ValueError( + "Only explicit return type defined but no explicit " + "argument types for method '{}'".format(function.__name__)) + ret_type = function.ret_type + elif has_arg_types: + raise ValueError( + "Only explicit argument types defined but no explicit return " + "for method '{}'".format(function.__name__)) + else: + ret_type = None + + return arg_types, ret_type + + +def get_arguments_getargspec(function, require_strong_typing): + """Verify arguments using the getargspec function.""" + args, vargs, kwargs, defaults = inspect.getargspec(function) + # Do not include 'dbus_*' parameters which have a special meaning + args = [a for a in args[1:] if not a.startswith("dbus_")] + arg_types, ret_type = extract_membered_types( + function, require_strong_typing, len(args)) + + if defaults is not None: + raise ValueError( + "Default values are not allowed for method " + "'{}'".format(function.__name__)) + if vargs is not None or kwargs is not None: + raise ValueError( + "Variable arguments arguments are not allowed for method " + "'{}'".format(function.__name__)) + return args, arg_types, ret_type + + +def get_arguments_signature(function, require_strong_typing): + """Verify arguments using the Signature class in Python 3.""" + signature = inspect.signature(function) + # For whatever reason OrderedDict does not actually support slicing + # Also do not include 'dbus_*' parameters which have a special meaning + parameters = [ + p for p in islice(signature.parameters.values(), 1, None) + if not p.name.startswith("dbus_")] + if not all(param.default is param.empty for param in parameters): + raise ValueError( + "Default values are not allowed for method " + "'{}'".format(function.__name__)) + if not all(param.kind == param.POSITIONAL_OR_KEYWORD + for param in parameters): + raise ValueError( + "Variable arguments or keyword only arguments are not allowed for " + "method '{}'".format(function.__name__)) + + names = [p.name for p in parameters] + arg_types = [ + param.annotation for param in parameters + if param.annotation is not param.empty] + if arg_types and hasattr(function, "arg_types"): + raise ValueError( + "Annotations and explicit argument types are used together in " + "method '{}'".format(function.__name__)) + + ret_type = signature.return_annotation + if (ret_type is not signature.empty and + hasattr(function, "ret_type")): + raise ValueError( + "Annotations and explicit return type are used together in " + "method '{}'".format(function.__name__)) + + # Fall back to the explicit types only if there were no annotations, but + # that might be actually valid if the function returns nothing and has + # no parameters. + # So it also checks that the function has any parameter or it has either of + # the two attributes defined. + # So it won't actually raise an error if a function has no parameter and + # no annotations and no explicit types defined, because it is not possible + # to determine if a function returns something. + if (ret_type is signature.empty and not arg_types and + (parameters or hasattr(function, "arg_types") or + hasattr(function, "ret_type"))): + arg_types, ret_type = extract_membered_types( + function, require_strong_typing, len(parameters)) + + if ret_type is signature.empty: + # Instead of 'empty' we use None as each type should be strings + ret_type = None + + return names, arg_types, ret_type + + +def get_arguments(function, require_strong_typing=False): + """Verify that the function is correctly defined.""" + if sys.version_info[0] == 2: + verify_func = get_arguments_getargspec + else: + verify_func = get_arguments_signature + + names, arg_types, ret_type = verify_func(function, require_strong_typing) + if len(arg_types) != len(names): + raise ValueError( + "Number of argument types ({}) differs from the number of " + "parameters ({}) in function '{}'".format( + len(arg_types), len(names), function.__name__)) + + arg_types = tuple(zip(names, arg_types)) + + return arg_types, ret_type + + +def generate_introspection_xml(cls, require_strong_typing=False): + """Generate introspection XML for the given class.""" + def get_interface(entry): + """Get the interface XML element for the given member.""" + if getattr(entry, "dbus_interface", None) is None: + interface = cls.dbus_interface + if interface is None: + raise ValueError( + "No interface defined for '{}'".format(entry.__name__)) + else: + interface = entry.dbus_interface + if interface not in interfaces: + interfaces[interface] = ElementTree.SubElement( + root, "interface", {"name": interface}) + return interfaces[interface] + + def valid_member(member): + """Only select members with the correct type and name.""" + if isinstance(member, property): + member = member.fget + elif not ismethod(member) and not isinstance(member, signal): + return False + return member.__name__[0].isupper() + + def add_arguments(**base_attributes): + for arg, arg_type in arg_types: + attrib = dict(base_attributes, name=arg) + if arg_type is not None: + attrib["type"] = arg_type + ElementTree.SubElement(entry, "arg", attrib) + + + interfaces = {} + root = ElementTree.Element("node") + for name, value in inspect.getmembers(cls, predicate=valid_member): + entry = None # in case something gets through + attributes = {"name": name} + if isinstance(value, property): + entry = ElementTree.SubElement( + get_interface(value.fget), "property") + if sys.version_info[0] == 3: + signature = inspect.signature(value.fget) + prop_type = signature.return_annotation + if prop_type is signature.empty: + prop_type = None + elif hasattr(function, "prop_type"): + raise ValueError( + "Annotations and explicit return type are used " + "together in method '{}'".format(function.__name__)) + else: + prop_type = None + if prop_type is None and hasattr(value.fget, "prop_type"): + prop_type = value.fget.prop_type + + if prop_type is not None: + attributes["type"] = prop_type + elif require_strong_typing: + raise ValueError( + "No type defined for property '{}'".format(name)) + + if value.fset is None: + attributes["access"] = "read" + else: + attributes["access"] = "readwrite" + if getattr(value.fget, "causes_signal", False) is True: + ElementTree.SubElement( + entry, "annotation", + {"name": PROPERTY_EMITS_SIGNAL, "value": "true"}) + elif isinstance(value, signal): + if hasattr(value, "method"): + arg_types, ret_type = get_arguments( + value.method, require_strong_typing) + if ret_type is not None: + raise ValueError( + "Return type defined for signal " + "'{}'".format(value.method.__name__)) + elif require_strong_typing: + raise ValueError( + "No argument definitions for signal " + "'{}'".format(value.method.__name__)) + else: + arg_types = tuple() + + entry = ElementTree.SubElement(get_interface(value), "signal") + add_arguments() + elif ismethod(value): + arg_types, ret_type = get_arguments(value, require_strong_typing) + entry = ElementTree.SubElement(get_interface(value), "method") + add_arguments(direction="in") + if ret_type is not None: + ElementTree.SubElement( + entry, "arg", + {"name": "return", "direction": "out", "type": ret_type}) + + entry.attrib = attributes + return ElementTree.tostring(root) + + +def attach_introspection_xml(cls): + """ + Generate and add introspection data to the class and return it. + + If used as a decorator without a parameter it won't require strong typing. + If the parameter is True or False, it'll require it depending ot it. + """ + def decorate(cls): + cls.dbus = generate_introspection_xml(cls, require_strong_typing) + return cls + if cls is True or cls is False: + require_strong_typing = cls + return decorate + else: + require_strong_typing = False + return decorate(cls) + + +def emits_changed_signal(prop): + """Decorate a property to emit a changing signal.""" + prop.fget.causes_signal = True + return prop + + +def interface(name): + """Define an interface for a method, property or class.""" + def decorate(obj): + obj.dbus_interface = name + return obj + return decorate diff --git a/tests/run.sh b/tests/run.sh index 436c840..ae22bce 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -17,6 +17,8 @@ PYTHON=${1:-python} "$PYTHON" -m pydbus.tests.context "$PYTHON" -m pydbus.tests.identifier +"$PYTHON" -m pydbus.tests.xml_generator +"$PYTHON" -m pydbus.tests.strong_typing if [ "$2" != "dontpublish" ] then "$PYTHON" -m pydbus.tests.publish