From 172cfb3e63855e377d20d4788b3b02a6ed04e20c Mon Sep 17 00:00:00 2001 From: Lennart Deters Date: Sat, 4 Feb 2023 02:42:30 +0100 Subject: [PATCH] allow Annotated as type annotation --- dbus_next/_private/util.py | 21 ++- docs/high-level-service/index.rst | 10 +- test/service/test_decorators_annotated.py | 162 ++++++++++++++++++++++ test/util.py | 20 +++ 4 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 test/service/test_decorators_annotated.py diff --git a/dbus_next/_private/util.py b/dbus_next/_private/util.py index cf20101..a3070c8 100644 --- a/dbus_next/_private/util.py +++ b/dbus_next/_private/util.py @@ -93,7 +93,20 @@ def _replace(idx): return body -def parse_annotation(annotation: str) -> str: +def select_annotated_metadata(annotation) -> str: + ''' + An PEP 593 Annotated instance (and the typing_extensions versions of that) + may contain multiple types of metadata, designed to be used by multiple + different libraries. To support that we only select string constants from + the annotation metadata and ignore others + ''' + for meta_annotation in annotation.__metadata__: + if type(meta_annotation) is str: + return meta_annotation + raise ValueError("service annotation using PEP 593 Annotated must contain a string constant") + + +def parse_annotation(annotation) -> str: ''' Because of PEP 563, if `from __future__ import annotations` is used in code or on Python version >=3.10 where this is the default, return annotations @@ -106,6 +119,12 @@ def raise_value_error(): if not annotation or annotation is inspect.Signature.empty: return '' + + # checking with hasattr because th python 3.6 version of + # typing_extensions.Annotated does not support isinstance + if hasattr(annotation, "__metadata__"): + annotation = select_annotated_metadata(annotation) + if type(annotation) is not str: raise_value_error() try: diff --git a/docs/high-level-service/index.rst b/docs/high-level-service/index.rst index 91cb2c5..74162ae 100644 --- a/docs/high-level-service/index.rst +++ b/docs/high-level-service/index.rst @@ -33,6 +33,10 @@ If any file descriptors are sent or received (DBus type ``h``), the variable ref method, dbus_property, signal) from dbus_next import Variant, DBusError + # Optional: + from typing_extensions import Annotated + # from typing import Annotated # Python >= 3.9 + import asyncio class ExampleInterface(ServiceInterface): @@ -64,12 +68,14 @@ If any file descriptors are sent or received (DBus type ``h``), the variable ref def Changed(self) -> 'b': return True + # Alternative type hint using Annotated. + # Requires either Python 3.9 or typing-extensions @dbus_property() - def Bar(self) -> 'y': + def Bar(self) -> Annotated[int, 'y']: return self._bar @Bar.setter - def Bar(self, val: 'y'): + def Bar(self, val: Annotated[int, 'y']): if self._bar == val: return diff --git a/test/service/test_decorators_annotated.py b/test/service/test_decorators_annotated.py new file mode 100644 index 0000000..5408178 --- /dev/null +++ b/test/service/test_decorators_annotated.py @@ -0,0 +1,162 @@ +from typing import List +from dbus_next import PropertyAccess, introspection as intr +from dbus_next.service import method, signal, dbus_property, ServiceInterface + +from test.util import check_annotated, skip_reason_no_typing_annotated + +import pytest + +has_annotated = check_annotated() + +if has_annotated: + from test.util import Annotated + + class ExampleInterface(ServiceInterface): + def __init__(self): + super().__init__('test.interface') + self._some_prop = 55 + self._another_prop = 101 + self._weird_prop = 500 + + @method() + def some_method(self, one: Annotated[str, 's'], two: Annotated[str, + 's']) -> Annotated[str, 's']: + return 'hello' + + @method(name='renamed_method', disabled=True) + def another_method(self, eight: Annotated[str, 'o'], six: Annotated[str, 't']): + pass + + @signal() + def some_signal(self) -> Annotated[List[str], 'as']: + return ['result'] + + @signal(name='renamed_signal', disabled=True) + def another_signal(self) -> Annotated[List, '(dodo)']: + return [1, '/', 1, '/'] + + # an additional annotation is added to this one. PEP 593 says we shoould be + # able to deal with that and only select the annotation that we understand + @dbus_property(name='renamed_readonly_property', access=PropertyAccess.READ, disabled=True) + def another_prop(self) -> Annotated[int, 42, 't']: + return self._another_prop + + @dbus_property() + def some_prop(self) -> Annotated[int, 'u']: + return self._some_prop + + @some_prop.setter + def some_prop(self, val: Annotated[int, 'u']): + self._some_prop = val + 1 + + # for this one, the setter has a different name than the getter which is a + # special case in the code + @dbus_property() + def weird_prop(self) -> Annotated[int, 't']: + return self._weird_prop + + @weird_prop.setter + def setter_for_weird_prop(self, val: Annotated[int, 't']): + self._weird_prop = val + + +@pytest.mark.skipif(not has_annotated, reason=skip_reason_no_typing_annotated) +def test_method_decorator(): + interface = ExampleInterface() + assert interface.name == 'test.interface' + + properties = ServiceInterface._get_properties(interface) + methods = ServiceInterface._get_methods(interface) + signals = ServiceInterface._get_signals(interface) + + assert len(methods) == 2 + + method = methods[0] + assert method.name == 'renamed_method' + assert method.in_signature == 'ot' + assert method.out_signature == '' + assert method.disabled + assert type(method.introspection) is intr.Method + + method = methods[1] + assert method.name == 'some_method' + assert method.in_signature == 'ss' + assert method.out_signature == 's' + assert not method.disabled + assert type(method.introspection) is intr.Method + + assert len(signals) == 2 + + signal = signals[0] + assert signal.name == 'renamed_signal' + assert signal.signature == '(dodo)' + assert signal.disabled + assert type(signal.introspection) is intr.Signal + + signal = signals[1] + assert signal.name == 'some_signal' + assert signal.signature == 'as' + assert not signal.disabled + assert type(signal.introspection) is intr.Signal + + assert len(properties) == 3 + + renamed_readonly_prop = properties[0] + assert renamed_readonly_prop.name == 'renamed_readonly_property' + assert renamed_readonly_prop.signature == 't' + assert renamed_readonly_prop.access == PropertyAccess.READ + assert renamed_readonly_prop.disabled + assert type(renamed_readonly_prop.introspection) is intr.Property + + weird_prop = properties[1] + assert weird_prop.name == 'weird_prop' + assert weird_prop.access == PropertyAccess.READWRITE + assert weird_prop.signature == 't' + assert not weird_prop.disabled + assert weird_prop.prop_getter is not None + assert weird_prop.prop_getter.__name__ == 'weird_prop' + assert weird_prop.prop_setter is not None + assert weird_prop.prop_setter.__name__ == 'setter_for_weird_prop' + assert type(weird_prop.introspection) is intr.Property + + prop = properties[2] + assert prop.name == 'some_prop' + assert prop.access == PropertyAccess.READWRITE + assert prop.signature == 'u' + assert not prop.disabled + assert prop.prop_getter is not None + assert prop.prop_setter is not None + assert type(prop.introspection) is intr.Property + + # make sure the getter and setter actually work + assert interface._some_prop == 55 + interface._some_prop = 555 + assert interface.some_prop == 555 + + assert interface._weird_prop == 500 + assert weird_prop.prop_getter(interface) == 500 + interface._weird_prop = 1001 + assert interface._weird_prop == 1001 + weird_prop.prop_setter(interface, 600) + assert interface._weird_prop == 600 + + +@pytest.mark.skipif(not has_annotated, reason=skip_reason_no_typing_annotated) +def test_interface_introspection(): + interface = ExampleInterface() + intr_interface = interface.introspect() + assert type(intr_interface) is intr.Interface + + xml = intr_interface.to_xml() + + assert xml.tag == 'interface' + assert xml.attrib.get('name', None) == 'test.interface' + + methods = xml.findall('method') + signals = xml.findall('signal') + properties = xml.findall('property') + + assert len(xml) == 4 + assert len(methods) == 1 + assert len(signals) == 1 + assert len(properties) == 2 diff --git a/test/util.py b/test/util.py index 9bba7d7..34bd159 100644 --- a/test/util.py +++ b/test/util.py @@ -13,3 +13,23 @@ def check_gi_repository(): except ImportError: _has_gi = False return _has_gi + + +_has_annotated = False +import typing +if hasattr(typing, "Annotated"): + from typing import Annotated + _has_annotated = True +else: + try: + from typing_extensions import Annotated + _has_annotated = True + except ImportError: + pass + +skip_reason_no_typing_annotated = 'Annotated tests require python 3.9 or typing-extensions' + + +def check_annotated(): + global _has_annotated + return _has_annotated