Skip to content

Commit bd0924a

Browse files
authored
Add type annotations to Callable objects when possible. Fix #17 (#21)
* Add type annotations to Callable objects when possible. Fix #17 **On python3 only** For `logwrap`: use mypy comment type annotation (`# type: ...`) For `pretty_str` and `pretty_repr`: use native annotations * Update example in README
1 parent f116596 commit bd0924a

File tree

5 files changed

+285
-115
lines changed

5 files changed

+285
-115
lines changed

README.rst

+7-7
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ Get decorator for use without parameters:
116116
117117
Call example:
118118

119-
.. code-block:: python3
119+
.. code-block:: python
120120
121121
import logwrap
122122
@@ -140,18 +140,18 @@ This code during execution will produce log records:
140140
Calling:
141141
'example_function1'(
142142
# POSITIONAL_OR_KEYWORD:
143-
'arg1'=u'''arg1''',
144-
'arg2'=u'''arg2''',
143+
'arg1'=u'''arg1''', # type: <class 'str'>
144+
'arg2'=u'''arg2''', # type: <class 'str'>
145145
# VAR_POSITIONAL:
146146
'args'=(),
147147
# KEYWORD_ONLY:
148-
'kwarg1'=u'''kwarg1''',
149-
'kwarg2'=u'''kwarg2''',
148+
'kwarg1'=u'''kwarg1''', # type: <class 'str'>
149+
'kwarg2'=u'''kwarg2''', # type: <class 'str'>
150150
# VAR_KEYWORD:
151151
'kwargs'=
152-
dict({
152+
dict({
153153
'kwarg3': u'''kwarg3''',
154-
}),
154+
}),
155155
)
156156
Done: 'example_function1' with result:
157157

logwrap/_log_wrap_shared.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949

5050

5151
indent = 4
52-
fmt = "\n{spc:<{indent}}{{key!r}}={{val}},".format(
52+
fmt = "\n{spc:<{indent}}{{key!r}}={{val}},{{annotation}}".format(
5353
spc='',
5454
indent=indent,
5555
).format
@@ -580,8 +580,14 @@ def _get_func_args_repr(
580580
param_str += comment(kind=param.kind)
581581
last_kind = param.kind
582582

583+
if param.empty == param.annotation:
584+
annotation = ""
585+
else:
586+
annotation = " # type: {param.annotation!s}".format(param=param)
587+
583588
param_str += fmt(
584589
key=param.name,
590+
annotation=annotation,
585591
val=val,
586592
)
587593
if param_str:

logwrap/_repr_utils.py

+117-37
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,93 @@ def _simple(item): # type: (typing.Any) -> bool
5454
return not isinstance(item, (list, set, tuple, dict, frozenset))
5555

5656

57+
class ReprParameter(object):
58+
"""Parameter wrapper wor repr and str operations over signature."""
59+
60+
__slots__ = (
61+
'_value',
62+
'_parameter'
63+
)
64+
65+
POSITIONAL_ONLY = Parameter.POSITIONAL_ONLY
66+
POSITIONAL_OR_KEYWORD = Parameter.POSITIONAL_OR_KEYWORD
67+
VAR_POSITIONAL = Parameter.VAR_POSITIONAL
68+
KEYWORD_ONLY = Parameter.KEYWORD_ONLY
69+
VAR_KEYWORD = Parameter.VAR_KEYWORD
70+
71+
empty = Parameter.empty
72+
73+
def __init__(
74+
self,
75+
parameter, # type: Parameter
76+
value=None # type: typing.Optional[typing.Any]
77+
): # type: (...) -> None
78+
"""Parameter-like object store for repr and str tasks.
79+
80+
:param parameter: parameter from signature
81+
:type parameter: inspect.Parameter
82+
:param value: default value override
83+
:type value: typing.Any
84+
"""
85+
self._parameter = parameter
86+
self._value = value if value is not None else parameter.default
87+
88+
@property
89+
def parameter(self): # type: () -> Parameter
90+
"""Parameter object."""
91+
return self._parameter
92+
93+
@property
94+
def name(self): # type: () -> typing.Union[None, str]
95+
"""Parameter name.
96+
97+
For `*args` and `**kwargs` add prefixes
98+
"""
99+
if self.kind == Parameter.VAR_POSITIONAL:
100+
return '*' + self.parameter.name
101+
elif self.kind == Parameter.VAR_KEYWORD:
102+
return '**' + self.parameter.name
103+
return self.parameter.name
104+
105+
@property
106+
def value(self): # type: () -> typing.Any
107+
"""Parameter value to log.
108+
109+
If function is bound to class -> value is class instance else default value.
110+
"""
111+
return self._value
112+
113+
@property
114+
def annotation(self): # type: () -> typing.Union[Parameter.empty, str]
115+
"""Parameter annotation."""
116+
return self.parameter.annotation
117+
118+
@property
119+
def kind(self): # type: () -> int
120+
"""Parameter kind."""
121+
return self.parameter.kind
122+
123+
def __hash__(self): # pragma: no cover
124+
"""Block hashing.
125+
126+
:raises TypeError: Not hashable.
127+
"""
128+
msg = "unhashable type: '{0}'".format(self.__class__.__name__)
129+
raise TypeError(msg)
130+
131+
def __repr__(self):
132+
"""Debug purposes."""
133+
return '<{} "{}">'.format(self.__class__.__name__, self)
134+
135+
57136
# pylint: disable=no-member
58137
def _prepare_repr(
59138
func # type: typing.Union[types.FunctionType, types.MethodType]
60-
): # type: (...) -> typing.Iterator[typing.Union[str, typing.Tuple[str, typing.Any]]]
139+
): # type: (...) -> typing.Iterator[ReprParameter]
61140
"""Get arguments lists with defaults.
62141
63142
:type func: typing.Union[types.FunctionType, types.MethodType]
64-
:rtype: typing.Iterator[typing.Union[str, typing.Tuple[str, typing.Any]]]
143+
:rtype: typing.Iterator[ReprParameter]
65144
"""
66145
isfunction = isinstance(func, types.FunctionType)
67146
real_func = func if isfunction else func.__func__ # type: typing.Callable
@@ -71,18 +150,11 @@ def _prepare_repr(
71150
params = iter(parameters)
72151
if not isfunction and func.__self__ is not None:
73152
try:
74-
yield next(params).name, func.__self__
153+
yield ReprParameter(next(params), value=func.__self__)
75154
except StopIteration: # pragma: no cover
76155
return
77156
for arg in params:
78-
if arg.default != Parameter.empty:
79-
yield arg.name, arg.default
80-
elif arg.kind == Parameter.VAR_POSITIONAL:
81-
yield '*' + arg.name
82-
elif arg.kind == Parameter.VAR_KEYWORD:
83-
yield '**' + arg.name
84-
else:
85-
yield arg.name
157+
yield ReprParameter(arg)
86158
# pylint: enable=no-member
87159

88160

@@ -455,31 +527,35 @@ def _repr_callable(
455527
param_str = ""
456528

457529
for param in _prepare_repr(src):
458-
if isinstance(param, tuple):
459-
param_str += "\n{spc:<{indent}}{key}={val},".format(
460-
spc='',
461-
indent=self.next_indent(indent),
462-
key=param[0],
530+
param_str += "\n{spc:<{indent}}{param.name}".format(
531+
spc='',
532+
indent=self.next_indent(indent),
533+
param=param
534+
)
535+
if param.annotation != param.empty:
536+
param_str += ': {param.annotation}'.format(param=param)
537+
if param.value != param.empty:
538+
param_str += '={val}'.format(
463539
val=self.process_element(
464-
src=param[1],
540+
src=param.value,
465541
indent=indent,
466542
no_indent_start=True,
467543
)
468544
)
469-
else:
470-
param_str += "\n{spc:<{indent}}{key},".format(
471-
spc='',
472-
indent=self.next_indent(indent),
473-
key=param
474-
)
545+
param_str += ','
475546

476547
if param_str:
477548
param_str += "\n" + " " * indent
478-
return "\n{spc:<{indent}}<{obj!r} with interface ({args})>".format(
549+
550+
sig = signature(src)
551+
annotation = '' if sig.return_annotation == Parameter.empty else ' -> {sig.return_annotation!r}'.format(sig=sig)
552+
553+
return "\n{spc:<{indent}}<{obj!r} with interface ({args}){annotation}>".format(
479554
spc="",
480555
indent=indent,
481556
obj=src,
482557
args=param_str,
558+
annotation=annotation
483559
)
484560

485561
@staticmethod
@@ -627,31 +703,35 @@ def _repr_callable(
627703
param_str = ""
628704

629705
for param in _prepare_repr(src):
630-
if isinstance(param, tuple):
631-
param_str += "\n{spc:<{indent}}{key}={val},".format(
632-
spc='',
633-
indent=self.next_indent(indent),
634-
key=param[0],
706+
param_str += "\n{spc:<{indent}}{param.name}".format(
707+
spc='',
708+
indent=self.next_indent(indent),
709+
param=param
710+
)
711+
if param.annotation != param.empty:
712+
param_str += ': {param.annotation}'.format(param=param)
713+
if param.value != param.empty:
714+
param_str += '={val}'.format(
635715
val=self.process_element(
636-
src=param[1],
716+
src=param.value,
637717
indent=indent,
638718
no_indent_start=True,
639719
)
640720
)
641-
else:
642-
param_str += "\n{spc:<{indent}}{key},".format(
643-
spc='',
644-
indent=self.next_indent(indent),
645-
key=param
646-
)
721+
param_str += ','
647722

648723
if param_str:
649724
param_str += "\n" + " " * indent
650-
return "\n{spc:<{indent}}<{obj!s} with interface ({args})>".format(
725+
726+
sig = signature(src)
727+
annotation = '' if sig.return_annotation == Parameter.empty else ' -> {sig.return_annotation!r}'.format(sig=sig)
728+
729+
return "\n{spc:<{indent}}<{obj!s} with interface ({args}){annotation}>".format(
651730
spc="",
652731
indent=indent,
653732
obj=src,
654733
args=param_str,
734+
annotation=annotation
655735
)
656736

657737
@staticmethod

test/test_log_wrap_py3.py

+41
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
except ImportError:
2222
asyncio = None
2323
import logging
24+
import sys
25+
import typing # noqa # pylint: disable=unused-import
2426
import unittest
2527
try:
2628
from unittest import mock
@@ -139,3 +141,42 @@ def func():
139141
),
140142
]
141143
)
144+
145+
146+
# noinspection PyUnusedLocal,PyMissingOrEmptyDocstring
147+
@mock.patch('logwrap._log_wrap_shared.logger', autospec=True)
148+
@unittest.skipIf(
149+
sys.version_info[:2] < (3, 4),
150+
'Strict python 3.3+ API'
151+
)
152+
class TestAnnotated(unittest.TestCase):
153+
def test_annotation_args(self, logger):
154+
namespace = {'logwrap': logwrap}
155+
156+
exec("""
157+
import typing
158+
@logwrap.logwrap
159+
def func(a: typing.Optional[int]=None):
160+
pass
161+
""",
162+
namespace
163+
)
164+
func = namespace['func'] # type: typing.Callable[..., None]
165+
func()
166+
self.assertEqual(
167+
logger.mock_calls,
168+
[
169+
mock.call.log(
170+
level=logging.DEBUG,
171+
msg="Calling: \n"
172+
"'func'(\n"
173+
" # POSITIONAL_OR_KEYWORD:\n"
174+
" 'a'=None, # type: typing.Union[int, NoneType]\n"
175+
")"
176+
),
177+
mock.call.log(
178+
level=logging.DEBUG,
179+
msg="Done: 'func' with result:\nNone"
180+
)
181+
]
182+
)

0 commit comments

Comments
 (0)