Repository: python-useful-helpers/logwrap

Issue:
Add type annotations to Callable objects when possible
On python 3 typing annotations is accessible and should be used in logging.

Discussion:

Patch:
diff --git a/README.rst b/README.rst
index 7d46a7e..03d3821 100644
--- a/README.rst
+++ b/README.rst
@@ -116,7 +116,7 @@ Get decorator for use without parameters:
 
 Call example:
 
-.. code-block:: python3
+.. code-block:: python
 
    import logwrap
 
@@ -140,18 +140,18 @@ This code during execution will produce log records:
     Calling:
     'example_function1'(
         # POSITIONAL_OR_KEYWORD:
-        'arg1'=u'''arg1''',
-        'arg2'=u'''arg2''',
+        'arg1'=u'''arg1''',  # type: <class 'str'>
+        'arg2'=u'''arg2''',  # type: <class 'str'>
         # VAR_POSITIONAL:
         'args'=(),
         # KEYWORD_ONLY:
-        'kwarg1'=u'''kwarg1''',
-        'kwarg2'=u'''kwarg2''',
+        'kwarg1'=u'''kwarg1''',  # type: <class 'str'>
+        'kwarg2'=u'''kwarg2''',  # type: <class 'str'>
         # VAR_KEYWORD:
         'kwargs'=
-             dict({
+            dict({
                 'kwarg3': u'''kwarg3''',
-             }),
+            }),
     )
     Done: 'example_function1' with result:
 
diff --git a/logwrap/_log_wrap_shared.py b/logwrap/_log_wrap_shared.py
index 274454c..0e076ed 100644
--- a/logwrap/_log_wrap_shared.py
+++ b/logwrap/_log_wrap_shared.py
@@ -49,7 +49,7 @@ logger = logging.getLogger(__name__)  # type: logging.Logger
 
 
 indent = 4
-fmt = "\n{spc:<{indent}}{{key!r}}={{val}},".format(
+fmt = "\n{spc:<{indent}}{{key!r}}={{val}},{{annotation}}".format(
     spc='',
     indent=indent,
 ).format
@@ -580,8 +580,14 @@ class BaseLogWrap(_class_decorator.BaseDecorator):
                 param_str += comment(kind=param.kind)
                 last_kind = param.kind
 
+            if param.empty == param.annotation:
+                annotation = ""
+            else:
+                annotation = "  # type: {param.annotation!s}".format(param=param)
+
             param_str += fmt(
                 key=param.name,
+                annotation=annotation,
                 val=val,
             )
         if param_str:
diff --git a/logwrap/_log_wrap_shared.pyi b/logwrap/_log_wrap_shared.pyi
index 88102d5..37b2880 100644
--- a/logwrap/_log_wrap_shared.pyi
+++ b/logwrap/_log_wrap_shared.pyi
@@ -27,11 +27,11 @@ class BoundParameter(object):
         '_value'
     )
 
-    POSITIONAL_ONLY = Parameter.POSITIONAL_ONLY  # type: enum.IntEnum
-    POSITIONAL_OR_KEYWORD = Parameter.POSITIONAL_OR_KEYWORD  # type: enum.IntEnum
-    VAR_POSITIONAL = Parameter.VAR_POSITIONAL  # type: enum.IntEnum
-    KEYWORD_ONLY = Parameter.KEYWORD_ONLY  # type: enum.IntEnum
-    VAR_KEYWORD = Parameter.VAR_KEYWORD  # type: enum.IntEnum
+    POSITIONAL_ONLY = Parameter.POSITIONAL_ONLY
+    POSITIONAL_OR_KEYWORD = Parameter.POSITIONAL_OR_KEYWORD
+    VAR_POSITIONAL = Parameter.VAR_POSITIONAL
+    KEYWORD_ONLY = Parameter.KEYWORD_ONLY
+    VAR_KEYWORD = Parameter.VAR_KEYWORD
 
     empty = Parameter.empty  # type: typing.Type
 
diff --git a/logwrap/_repr_utils.py b/logwrap/_repr_utils.py
index 987e183..a9752aa 100644
--- a/logwrap/_repr_utils.py
+++ b/logwrap/_repr_utils.py
@@ -54,14 +54,93 @@ def _simple(item):  # type: (typing.Any) -> bool
     return not isinstance(item, (list, set, tuple, dict, frozenset))
 
 
+class ReprParameter(object):
+    """Parameter wrapper wor repr and str operations over signature."""
+
+    __slots__ = (
+        '_value',
+        '_parameter'
+    )
+
+    POSITIONAL_ONLY = Parameter.POSITIONAL_ONLY
+    POSITIONAL_OR_KEYWORD = Parameter.POSITIONAL_OR_KEYWORD
+    VAR_POSITIONAL = Parameter.VAR_POSITIONAL
+    KEYWORD_ONLY = Parameter.KEYWORD_ONLY
+    VAR_KEYWORD = Parameter.VAR_KEYWORD
+
+    empty = Parameter.empty
+
+    def __init__(
+        self,
+        parameter,  # type: Parameter
+        value=None  # type: typing.Optional[typing.Any]
+    ):  # type: (...) -> None
+        """Parameter-like object store for repr and str tasks.
+
+        :param parameter: parameter from signature
+        :type parameter: inspect.Parameter
+        :param value: default value override
+        :type value: typing.Any
+        """
+        self._parameter = parameter
+        self._value = value if value is not None else parameter.default
+
+    @property
+    def parameter(self):  # type: () -> Parameter
+        """Parameter object."""
+        return self._parameter
+
+    @property
+    def name(self):  # type: () -> typing.Union[None, str]
+        """Parameter name.
+
+        For `*args` and `**kwargs` add prefixes
+        """
+        if self.kind == Parameter.VAR_POSITIONAL:
+            return '*' + self.parameter.name
+        elif self.kind == Parameter.VAR_KEYWORD:
+            return '**' + self.parameter.name
+        return self.parameter.name
+
+    @property
+    def value(self):  # type: () -> typing.Any
+        """Parameter value to log.
+
+        If function is bound to class -> value is class instance else default value.
+        """
+        return self._value
+
+    @property
+    def annotation(self):  # type: () -> typing.Union[Parameter.empty, str]
+        """Parameter annotation."""
+        return self.parameter.annotation
+
+    @property
+    def kind(self):  # type: () -> int
+        """Parameter kind."""
+        return self.parameter.kind
+
+    def __hash__(self):  # pragma: no cover
+        """Block hashing.
+
+        :raises TypeError: Not hashable.
+        """
+        msg = "unhashable type: '{0}'".format(self.__class__.__name__)
+        raise TypeError(msg)
+
+    def __repr__(self):
+        """Debug purposes."""
+        return '<{} "{}">'.format(self.__class__.__name__, self)
+
+
 # pylint: disable=no-member
 def _prepare_repr(
     func  # type: typing.Union[types.FunctionType, types.MethodType]
-):  # type: (...) -> typing.Iterator[typing.Union[str, typing.Tuple[str, typing.Any]]]
+):  # type: (...) -> typing.Iterator[ReprParameter]
     """Get arguments lists with defaults.
 
     :type func: typing.Union[types.FunctionType, types.MethodType]
-    :rtype: typing.Iterator[typing.Union[str, typing.Tuple[str, typing.Any]]]
+    :rtype: typing.Iterator[ReprParameter]
     """
     isfunction = isinstance(func, types.FunctionType)
     real_func = func if isfunction else func.__func__  # type: typing.Callable
@@ -71,18 +150,11 @@ def _prepare_repr(
     params = iter(parameters)
     if not isfunction and func.__self__ is not None:
         try:
-            yield next(params).name, func.__self__
+            yield ReprParameter(next(params), value=func.__self__)
         except StopIteration:  # pragma: no cover
             return
     for arg in params:
-        if arg.default != Parameter.empty:
-            yield arg.name, arg.default
-        elif arg.kind == Parameter.VAR_POSITIONAL:
-            yield '*' + arg.name
-        elif arg.kind == Parameter.VAR_KEYWORD:
-            yield '**' + arg.name
-        else:
-            yield arg.name
+        yield ReprParameter(arg)
 # pylint: enable=no-member
 
 
@@ -455,31 +527,35 @@ class PrettyRepr(PrettyFormat):
         param_str = ""
 
         for param in _prepare_repr(src):
-            if isinstance(param, tuple):
-                param_str += "\n{spc:<{indent}}{key}={val},".format(
-                    spc='',
-                    indent=self.next_indent(indent),
-                    key=param[0],
+            param_str += "\n{spc:<{indent}}{param.name}".format(
+                spc='',
+                indent=self.next_indent(indent),
+                param=param
+            )
+            if param.annotation != param.empty:
+                param_str += ': {param.annotation}'.format(param=param)
+            if param.value != param.empty:
+                param_str += '={val}'.format(
                     val=self.process_element(
-                        src=param[1],
+                        src=param.value,
                         indent=indent,
                         no_indent_start=True,
                     )
                 )
-            else:
-                param_str += "\n{spc:<{indent}}{key},".format(
-                    spc='',
-                    indent=self.next_indent(indent),
-                    key=param
-                )
+            param_str += ','
 
         if param_str:
             param_str += "\n" + " " * indent
-        return "\n{spc:<{indent}}<{obj!r} with interface ({args})>".format(
+
+        sig = signature(src)
+        annotation = '' if sig.return_annotation == Parameter.empty else ' -> {sig.return_annotation!r}'.format(sig=sig)
+
+        return "\n{spc:<{indent}}<{obj!r} with interface ({args}){annotation}>".format(
             spc="",
             indent=indent,
             obj=src,
             args=param_str,
+            annotation=annotation
         )
 
     @staticmethod
@@ -627,31 +703,35 @@ class PrettyStr(PrettyFormat):
         param_str = ""
 
         for param in _prepare_repr(src):
-            if isinstance(param, tuple):
-                param_str += "\n{spc:<{indent}}{key}={val},".format(
-                    spc='',
-                    indent=self.next_indent(indent),
-                    key=param[0],
+            param_str += "\n{spc:<{indent}}{param.name}".format(
+                spc='',
+                indent=self.next_indent(indent),
+                param=param
+            )
+            if param.annotation != param.empty:
+                param_str += ': {param.annotation}'.format(param=param)
+            if param.value != param.empty:
+                param_str += '={val}'.format(
                     val=self.process_element(
-                        src=param[1],
+                        src=param.value,
                         indent=indent,
                         no_indent_start=True,
                     )
                 )
-            else:
-                param_str += "\n{spc:<{indent}}{key},".format(
-                    spc='',
-                    indent=self.next_indent(indent),
-                    key=param
-                )
+            param_str += ','
 
         if param_str:
             param_str += "\n" + " " * indent
-        return "\n{spc:<{indent}}<{obj!s} with interface ({args})>".format(
+
+        sig = signature(src)
+        annotation = '' if sig.return_annotation == Parameter.empty else ' -> {sig.return_annotation!r}'.format(sig=sig)
+
+        return "\n{spc:<{indent}}<{obj!s} with interface ({args}){annotation}>".format(
             spc="",
             indent=indent,
             obj=src,
             args=param_str,
+            annotation=annotation
         )
 
     @staticmethod
diff --git a/logwrap/py.typed b/logwrap/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/setup.py b/setup.py
index e454818..25ed0e7 100644
--- a/setup.py
+++ b/setup.py
@@ -267,6 +267,8 @@ setup_args = dict(
         'logwrap': [
             os.path.basename(filename)
             for filename in glob.glob(os.path.join('logwrap', '*.pyi'))
+        ] + [
+            'py.typed'
         ],
     },
 )


Test patch:
diff --git a/test/test_log_wrap_py3.py b/test/test_log_wrap_py3.py
index be03200..bd97db9 100644
--- a/test/test_log_wrap_py3.py
+++ b/test/test_log_wrap_py3.py
@@ -21,6 +21,8 @@ try:
 except ImportError:
     asyncio = None
 import logging
+import sys
+import typing  # noqa # pylint: disable=unused-import
 import unittest
 try:
     from unittest import mock
@@ -139,3 +141,42 @@ class TestLogWrapAsync(unittest.TestCase):
                 ),
             ]
         )
+
+
+# noinspection PyUnusedLocal,PyMissingOrEmptyDocstring
+@mock.patch('logwrap._log_wrap_shared.logger', autospec=True)
+@unittest.skipIf(
+    sys.version_info[:2] < (3, 4),
+    'Strict python 3.3+ API'
+)
+class TestAnnotated(unittest.TestCase):
+    def test_annotation_args(self, logger):
+        namespace = {'logwrap': logwrap}
+
+        exec("""
+import typing
+@logwrap.logwrap
+def func(a: typing.Optional[int]=None):
+    pass
+                        """,
+             namespace
+             )
+        func = namespace['func']  # type: typing.Callable[..., None]
+        func()
+        self.assertEqual(
+            logger.mock_calls,
+            [
+                mock.call.log(
+                    level=logging.DEBUG,
+                    msg="Calling: \n"
+                        "'func'(\n"
+                        "    # POSITIONAL_OR_KEYWORD:\n"
+                        "    'a'=None,  # type: typing.Union[int, NoneType]\n"
+                        ")"
+                ),
+                mock.call.log(
+                    level=logging.DEBUG,
+                    msg="Done: 'func' with result:\nNone"
+                )
+            ]
+        )
diff --git a/test/test_repr_utils.py b/test/test_repr_utils.py
index b3edea6..2e45147 100644
--- a/test/test_repr_utils.py
+++ b/test/test_repr_utils.py
@@ -21,11 +21,10 @@
 from __future__ import absolute_import
 from __future__ import unicode_literals
 
+import sys
 import unittest
 
 import logwrap
-# noinspection PyProtectedMember
-from logwrap import _repr_utils
 
 
 # noinspection PyUnusedLocal,PyMissingOrEmptyDocstring
@@ -113,74 +112,6 @@ class TestPrettyRepr(unittest.TestCase):
         )
         self.assertEqual(exp_repr, logwrap.pretty_repr(test_obj))
 
-    def test_prepare_repr(self):
-        def empty_func():
-            pass
-
-        def full_func(arg, darg=1, *positional, **named):
-            pass
-
-        # noinspection PyMissingOrEmptyDocstring
-        class TstClass(object):
-            def tst_method(self, arg, darg=1, *positional, **named):
-                pass
-
-            @classmethod
-            def tst_classmethod(cls, arg, darg=1, *positional, **named):
-                pass
-
-            @staticmethod
-            def tst_staticmethod(arg, darg=1, *positional, **named):
-                pass
-
-        tst_instance = TstClass()
-
-        self.assertEqual(
-            list(_repr_utils._prepare_repr(empty_func)),
-            []
-        )
-
-        self.assertEqual(
-            list(_repr_utils._prepare_repr(full_func)),
-            ['arg', ('darg', 1), '*positional', '**named']
-        )
-
-        self.assertEqual(
-            list(_repr_utils._prepare_repr(TstClass.tst_method)),
-            ['self', 'arg', ('darg', 1), '*positional', '**named']
-        )
-
-        self.assertEqual(
-            list(_repr_utils._prepare_repr(TstClass.tst_classmethod)),
-            [('cls', TstClass), 'arg', ('darg', 1), '*positional', '**named']
-        )
-
-        self.assertEqual(
-            list(_repr_utils._prepare_repr(TstClass.tst_staticmethod)),
-            ['arg', ('darg', 1), '*positional', '**named']
-        )
-
-        self.assertEqual(
-            list(_repr_utils._prepare_repr(tst_instance.tst_method)),
-            [
-                ('self', tst_instance),
-                'arg',
-                ('darg', 1),
-                '*positional',
-                '**named',
-            ]
-        )
-
-        self.assertEqual(
-            list(_repr_utils._prepare_repr(tst_instance.tst_classmethod)),
-            [('cls', TstClass), 'arg', ('darg', 1), '*positional', '**named']
-        )
-
-        self.assertEqual(
-            list(_repr_utils._prepare_repr(tst_instance.tst_staticmethod)),
-            ['arg', ('darg', 1), '*positional', '**named']
-        )
-
     def test_callable(self):
         fmt = "\n{spc:<{indent}}<{obj!r} with interface ({args})>".format
 
@@ -355,3 +286,115 @@ class TestPrettyRepr(unittest.TestCase):
 
     def test_py2_compatibility_flag(self):
         self.assertIsInstance(logwrap.pretty_repr(u'Text', py2_str=True), str)
+
+
+# noinspection PyUnusedLocal,PyMissingOrEmptyDocstring
+@unittest.skipIf(
+    sys.version_info[:2] < (3, 4),
+    'Strict python 3.3+ API'
+)
+class TestAnnotated(unittest.TestCase):
+    def test_001_annotation_args(self):
+        fmt = "\n{spc:<{indent}}<{obj!r} with interface ({args}){annotation}>".format
+        namespace = {}
+
+        exec("""
+import typing
+def func(a: typing.Optional[int]=None):
+    pass
+                        """,
+             namespace
+             )
+        func = namespace['func']  # type: typing.Callable[..., None]
+
+        self.assertEqual(
+            logwrap.pretty_repr(func),
+            fmt(
+                spc='',
+                indent=0,
+                obj=func,
+                args="\n    a: typing.Union[int, NoneType]=None,\n",
+                annotation=""
+            )
+        )
+
+        self.assertEqual(
+            logwrap.pretty_str(func),
+            fmt(
+                spc='',
+                indent=0,
+                obj=func,
+                args="\n    a: typing.Union[int, NoneType]=None,\n",
+                annotation=""
+            )
+        )
+
+    def test_002_annotation_return(self):
+        fmt = "\n{spc:<{indent}}<{obj!r} with interface ({args}){annotation}>".format
+        namespace = {}
+
+        exec("""
+import typing
+def func() -> None:
+    pass
+                                """,
+             namespace
+             )
+        func = namespace['func']  # type: typing.Callable[[], None]
+
+        self.assertEqual(
+            logwrap.pretty_repr(func),
+            fmt(
+                spc='',
+                indent=0,
+                obj=func,
+                args='',
+                annotation=' -> None'
+            )
+        )
+
+        self.assertEqual(
+            logwrap.pretty_str(func),
+            fmt(
+                spc='',
+                indent=0,
+                obj=func,
+                args='',
+                annotation=' -> None'
+            )
+        )
+
+    def test_003_complex(self):
+        fmt = "\n{spc:<{indent}}<{obj!r} with interface ({args}){annotation}>".format
+        namespace = {}
+
+        exec("""
+import typing
+def func(a: typing.Optional[int]=None) -> None:
+    pass
+                                """,
+             namespace
+             )
+        func = namespace['func']  # type: typing.Callable[..., None]
+
+        self.assertEqual(
+            logwrap.pretty_repr(func),
+            fmt(
+                spc='',
+                indent=0,
+                obj=func,
+                args="\n    a: typing.Union[int, NoneType]=None,\n",
+                annotation=" -> None"
+            )
+        )
+
+        self.assertEqual(
+            logwrap.pretty_str(func),
+            fmt(
+                spc='',
+                indent=0,
+                obj=func,
+                args="\n    a: typing.Union[int, NoneType]=None,\n",
+                annotation=" -> None"
+            )
+        )