NotOnlyVariadicFunctionTestCase   A
last analyzed

Complexity

Total Complexity 10

Size/Duplication

Total Lines 41
Duplicated Lines 0 %

Importance

Changes 6
Bugs 0 Features 0
Metric Value
c 6
b 0
f 0
dl 0
loc 41
rs 10
wmc 10

6 Methods

Rating   Name   Duplication   Size   Complexity  
A f() 0 3 1
A test_method() 0 2 1
A test_kwds_after_varargs() 0 6 2
A test_args_before_varargs() 0 6 2
A test_closures() 0 10 2
A test_defaults_on_args_before_varargs() 0 8 2
1
#!/usr/bin/env python
2
3
# Copyright 2015 Vincent Jacques <[email protected]>
4
5
"""
6
Introduction
7
============
8
9
Define a variadic function:
10
11
    >>> @variadic(int)
12
    ... def f(*xs):
13
    ...   return xs
14
15
It can be called with a variable number of arguments:
16
17
    >>> f()
18
    ()
19
    >>> f(1, 2, 3, 4)
20
    (1, 2, 3, 4)
21
22
So far, no change, but it can also be called with lists (any iterable, in fact) of arguments:
23
24
    >>> f([])
25
    ()
26
    >>> f([1, 2, 3], (4, 5, 6))
27
    (1, 2, 3, 4, 5, 6)
28
    >>> f(xrange(1, 4))
29
    (1, 2, 3)
30
31
And you can even mix them:
32
33
    >>> f(1, [2, 3], (4, 5), xrange(6, 8))
34
    (1, 2, 3, 4, 5, 6, 7)
35
36
Positional arguments, default values and keyword arguments are OK as well:
37
38
    >>> @variadic(int)
39
    ... def f(a, b=None, *cs, **kwds):
40
    ...   return a, b, cs, kwds
41
    >>> f(1)
42
    (1, None, (), {})
43
    >>> f(1, d=4)
44
    (1, None, (), {'d': 4})
45
    >>> f(1, 2, (3, 4), 5, d=6)
46
    (1, 2, (3, 4, 5), {'d': 6})
47
48
Pearls
49
======
50
51
Documentation generated by Sphinx for decorated functions
52
---------------------------------------------------------
53
54
It looks like a regular variadic function:
55
56
.. autofunction:: demo
57
58
TypeError raised when calling with bad arguments
59
------------------------------------------------
60
61
Exactly as if it was not decorated:
62
63
    >>> @variadic(int)
64
    ... def f(*xs):
65
    ...   pass
66
    >>> f(a=1)
67
    Traceback (most recent call last):
68
      File "<stdin>", line 1, in <module>
69
    TypeError: f() got an unexpected keyword argument 'a'
70
71
Exception raised by the decorated function
72
------------------------------------------
73
74
``@variadic`` adds just one stack frame with the same name as the decorated function:
75
76
    >>> @variadic(int)
77
    ... def f(*xs):
78
    ...   raise Exception
79
    >>> f()
80
    Traceback (most recent call last):
81
      File "<stdin>", line 1, in <module>
82
      File "<ast_in_variadic_py>", line 1, in f
83
      File "<stdin>", line 3, in f
84
    Exception
85
86
"""
87
88
import ast
89
import functools
90
import inspect
91
import itertools
92
import sys
93
import types
94
import unittest
95
96
# Todo-list:
97
# - allow passing the flatten function instead of typ
98
# - allow an even simpler usage without any parameters
99
# - add a parameter string to be prepended or appended to the docstring
100
# - support decorating callables that are not functions?
101
# - support keyword-only parameter (on Python 3)
102
103
104
# >>> help(types.FunctionType)
105
# class function(object)
106
#  |  function(code, globals[, name[, argdefs[, closure]]])
107
#  |
108
#  |  Create a function object from a code object and a dictionary.
109
#  |  The optional name string overrides the name from the code object.
110
#  |  The optional argdefs tuple specifies the default argument values.
111
#  |  The optional closure tuple supplies the bindings for free variables.
112
113
114
def variadic(typ):
115
    """
116
    Decorator taking a variadic function and making a very-variadic function from it:
117
    a function that can be called with a variable number of iterables of arguments.
118
119
    :param typ: the type (or tuple of types) of arguments expected.
120
        Variadic arguments that are instances of this type will be passed to the decorated function as-is.
121
        Others will be iterated and their contents will be passed.
122
    """
123
    def flatten(args):
124
        flat = []
125
        for arg in args:
126
            if isinstance(arg, typ):
127
                flat.append((arg,))
128
            else:
129
                flat.append(arg)
130
        return itertools.chain.from_iterable(flat)
131
132
    def decorator(wrapped):
133
        if sys.hexversion < 0x03000000:
134
            spec = inspect.getargspec(wrapped)
135
            assert spec.varargs is not None
136
            varargs = spec.varargs
137
            keywords = spec.keywords
138
        else:
139
            spec = inspect.getfullargspec(wrapped)
140
            assert spec.varargs is not None
141
            assert spec.kwonlyargs == []
142
            assert spec.kwonlydefaults is None
143
            assert spec.annotations == {}
144
            varargs = spec.varargs
145
            keywords = spec.varkw
146
147
        name = wrapped.__name__
148
149
        # Example was generated with print ast.dump(ast.parse("def f(a, b, *args, **kwds):
150
        # return call_wrapped((a, b), *args, **kwds)"), include_attributes=True)
151
        # http://code.activestate.com/recipes/578353-code-to-source-and-back/ helped a lot
152
        # http://stackoverflow.com/questions/10303248#29927459
153
154
        if sys.hexversion < 0x03000000:
155
            wrapper_ast_args = ast.arguments(
156
                args=[ast.Name(id=a, ctx=ast.Param(), lineno=1, col_offset=0) for a in spec.args],
157
                vararg=varargs,
158
                kwarg=keywords,
159
                defaults=[]
160
            )
161
        else:
162
            wrapper_ast_args = ast.arguments(
163
                args=[ast.arg(arg=a, annotation=None, lineno=1, col_offset=0) for a in spec.args],
164
                vararg=(
165
                    None if varargs is None else
166
                    ast.arg(arg=varargs, annotation=None, lineno=1, col_offset=0)
167
                ),
168
                kwonlyargs=[],
169
                kw_defaults=[],
170
                kwarg=(
171
                    None if keywords is None else
172
                    ast.arg(arg=keywords, annotation=None, lineno=1, col_offset=0)
173
                ),
174
                defaults=[]
175
            )
176
177
        wrapped_func = ast.Name(id="wrapped", ctx=ast.Load(), lineno=1, col_offset=0)
178
        wrapped_args = [ast.Name(id=a, ctx=ast.Load(), lineno=1, col_offset=0) for a in spec.args]
179
        flatten_func = ast.Name(id="flatten", ctx=ast.Load(), lineno=1, col_offset=0)
180
        flatten_args = [ast.Name(id=spec.varargs, ctx=ast.Load(), lineno=1, col_offset=0)]
181
182
        if sys.hexversion < 0x03050000:
183
            return_value = ast.Call(
184
                func=wrapped_func,
185
                args=wrapped_args,
186
                keywords=[],
187
                starargs=ast.Call(
188
                    func=flatten_func, args=flatten_args,
189
                    keywords=[], starargs=None, kwargs=None, lineno=1, col_offset=0
190
                ),
191
                kwargs=(
192
                    None if keywords is None else
193
                    ast.Name(id=keywords, ctx=ast.Load(), lineno=1, col_offset=0)
194
                ),
195
                lineno=1, col_offset=0
196
            )
197
        else:
198
            return_value = ast.Call(
199
                func=wrapped_func,
200
                args=wrapped_args + [
201
                    ast.Starred(
202
                        value=ast.Call(func=flatten_func, args=flatten_args, keywords=[], lineno=1, col_offset=0),
203
                        ctx=ast.Load(), lineno=1, col_offset=0
204
                    ),
205
                ],
206
                keywords=(
207
                    [] if keywords is None else
208
                    [ast.keyword(arg=None, value=ast.Name(id=keywords, ctx=ast.Load(), lineno=1, col_offset=0))]
209
                ),
210
                lineno=1, col_offset=0
211
            )
212
213
        wrapper_ast = ast.Module(body=[ast.FunctionDef(
214
            name=name,
215
            args=wrapper_ast_args,
216
            body=[ast.Return(value=return_value, lineno=1, col_offset=0)],
217
            decorator_list=[],
218
            lineno=1,
219
            col_offset=0
220
        )])
221
        wrapper_code = [
222
            c for c in compile(wrapper_ast, "<ast_in_variadic_py>", "exec").co_consts if isinstance(c, types.CodeType)
223
        ][0]
224
        wrapper = types.FunctionType(wrapper_code, {"wrapped": wrapped, "flatten": flatten}, argdefs=spec.defaults)
225
226
        functools.update_wrapper(wrapper, wrapped)
227
        return wrapper
228
    return decorator
229
230
231
class PurelyVariadicFunctionTestCase(unittest.TestCase):
232
    def setUp(self):
233
        @variadic(int)
234
        def f(*xs):
235
            "f's doc"
236
            return xs
237
238
        self.f = f
239
240
        @variadic(int)
241
        def g(*ys):
242
            "g's doc"
243
            return ys
244
245
        self.g = g
246
247
    def test_name_is_preserved(self):
248
        self.assertEqual(self.f.__name__, "f")
249
        self.assertEqual(self.g.__name__, "g")
250
251
    def test_doc_is_preserved(self):
252
        self.assertEqual(self.f.__doc__, "f's doc")
253
        self.assertEqual(self.g.__doc__, "g's doc")
254
255
    def test_argspec_keeps_param_name(self):
256
        self.assertEqual(inspect.getargspec(self.f).varargs, "xs")
257
        self.assertEqual(inspect.getargspec(self.g).varargs, "ys")
258
259
    def test_call_without_arguments(self):
260
        self.assertEqual(self.f(), ())
261
262
    def test_call_with_one_argument(self):
263
        self.assertEqual(self.f(1), (1,))
264
265
    def test_call_with_several_arguments(self):
266
        self.assertEqual(self.f(1, 2, 3), (1, 2, 3))
267
268
    def test_call_with_one_list(self):
269
        self.assertEqual(self.f([1, 2, 3]), (1, 2, 3))
270
271
    def test_call_with_several_lists(self):
272
        self.assertEqual(self.f([1, 2], [3], [4, 5]), (1, 2, 3, 4, 5))
273
274
    def test_call_with_lists_and_arguments(self):
275
        self.assertEqual(self.f([1, 2], 3, 4, [5, 6], 7), (1, 2, 3, 4, 5, 6, 7))
276
277
    def test_call_with_keywords(self):
278
        with self.assertRaises(TypeError) as catcher:
279
            self.f(a=1)
280
        self.assertEqual(catcher.exception.args, ("f() got an unexpected keyword argument 'a'",))
281
        with self.assertRaises(TypeError) as catcher:
282
            self.g(a=1)
283
        self.assertEqual(catcher.exception.args, ("g() got an unexpected keyword argument 'a'",))
284
285
286
class NotOnlyVariadicFunctionTestCase(unittest.TestCase):
287
    def test_args_before_varargs(self):
288
        @variadic(int)
289
        def f(a, b, *xs):
290
            return a, b, xs
291
292
        self.assertEqual(f(1, 2, 3, [4, 5], 6), (1, 2, (3, 4, 5, 6)))
293
294
    @variadic(int)
295
    def f(self, a, b, *xs):
296
        return self, a, b, xs
297
298
    def test_method(self):
299
        self.assertEqual(self.f(1, 2, 3, [4, 5], 6), (self, 1, 2, (3, 4, 5, 6)))
300
301
    def test_kwds_after_varargs(self):
302
        @variadic(int)
303
        def f(a, b, *xs, **kwds):
304
            return a, b, xs, kwds
305
306
        self.assertEqual(f(1, 2, 3, [4, 5], 6, c=7, d=8), (1, 2, (3, 4, 5, 6), {"c": 7, "d": 8}))
307
308
    def test_defaults_on_args_before_varargs(self):
309
        default = object()  # To avoid implementations wich would stringify the default values and feed them to exec.
310
311
        @variadic(int)
312
        def f(a=None, b=default, *xs):
313
            return a, b, xs
314
315
        self.assertEqual(f(), (None, default, ()))
316
317
    def test_closures(self):
318
        a = 42
319
320
        @variadic(int)
321
        def f(*xs):
322
            return a, xs
323
324
        self.assertEqual(f(1, 2), (42, (1, 2)))
325
        a = 57
326
        self.assertEqual(f(1, 2), (57, (1, 2)))
327
328
329
@variadic(int)
330
def demo(a, b=None, *xs, **kwds):
331
    """
332
    Demo function.
333
334
    :param a: A
335
    :param b: B
336
    :param xs: Xs
337
    :param kwds: keywords
338
    """
339
    pass
340
341
342
if __name__ == "__main__":
343
    unittest.main()
344