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