From 7e213255cee76f692dded963ec726b39319646c0 Mon Sep 17 00:00:00 2001 From: Benjamin Peterson Date: Tue, 30 Mar 2010 17:58:13 +0000 Subject: [PATCH] add inspect.getcallargs, which binds function arguments like a normal call #3135 Patch by George Sakkis --- Doc/library/inspect.rst | 26 ++++++ Lib/inspect.py | 85 ++++++++++++++++- Lib/test/test_inspect.py | 196 ++++++++++++++++++++++++++++++++++++++- Misc/NEWS | 3 + 4 files changed, 306 insertions(+), 4 deletions(-) diff --git a/Doc/library/inspect.rst b/Doc/library/inspect.rst index 29dc6ae9149..075b26f2fed 100644 --- a/Doc/library/inspect.rst +++ b/Doc/library/inspect.rst @@ -504,6 +504,32 @@ Classes and functions metatype is in use, cls will be the first element of the tuple. +.. function:: getcallargs(func[, *args][, **kwds]) + + Bind the *args* and *kwds* to the argument names of the Python function or + method *func*, as if it was called with them. For bound methods, bind also the + first argument (typically named ``self``) to the associated instance. A dict + is returned, mapping the argument names (including the names of the ``*`` and + ``**`` arguments, if any) to their values from *args* and *kwds*. In case of + invoking *func* incorrectly, i.e. whenever ``func(*args, **kwds)`` would raise + an exception because of incompatible signature, an exception of the same type + and the same or similar message is raised. For example:: + + >>> from inspect import getcallargs + >>> def f(a, b=1, *pos, **named): + ... pass + >>> getcallargs(f, 1, 2, 3) + {'a': 1, 'named': {}, 'b': 2, 'pos': (3,)} + >>> getcallargs(f, a=2, x=4) + {'a': 2, 'named': {'x': 4}, 'b': 1, 'pos': ()} + >>> getcallargs(f) + Traceback (most recent call last): + ... + TypeError: f() takes at least 1 argument (0 given) + + .. versionadded:: 2.7 + + .. _inspect-stack: The interpreter stack diff --git a/Lib/inspect.py b/Lib/inspect.py index e5098d798e5..5344893a430 100644 --- a/Lib/inspect.py +++ b/Lib/inspect.py @@ -17,7 +17,7 @@ Here are some of the useful functions provided by this module: getmodule() - determine the module that an object came from getclasstree() - arrange classes so as to represent their hierarchy - getargspec(), getargvalues() - get info about function arguments + getargspec(), getargvalues(), getcallargs() - get info about function arguments formatargspec(), formatargvalues() - format an argument spec getouterframes(), getinnerframes() - get info about frames currentframe() - get the current stack frame @@ -884,6 +884,89 @@ def formatargvalues(args, varargs, varkw, locals, specs.append(formatvarkw(varkw) + formatvalue(locals[varkw])) return '(' + string.join(specs, ', ') + ')' +def getcallargs(func, *positional, **named): + """Get the mapping of arguments to values. + + A dict is returned, with keys the function argument names (including the + names of the * and ** arguments, if any), and values the respective bound + values from 'positional' and 'named'.""" + args, varargs, varkw, defaults = getargspec(func) + f_name = func.__name__ + arg2value = {} + + # The following closures are basically because of tuple parameter unpacking. + assigned_tuple_params = [] + def assign(arg, value): + if isinstance(arg, str): + arg2value[arg] = value + else: + assigned_tuple_params.append(arg) + value = iter(value) + for i, subarg in enumerate(arg): + try: + subvalue = next(value) + except StopIteration: + raise ValueError('need more than %d %s to unpack' % + (i, 'values' if i > 1 else 'value')) + assign(subarg,subvalue) + try: + next(value) + except StopIteration: + pass + else: + raise ValueError('too many values to unpack') + def is_assigned(arg): + if isinstance(arg,str): + return arg in arg2value + return arg in assigned_tuple_params + if ismethod(func) and func.im_self is not None: + # implicit 'self' (or 'cls' for classmethods) argument + positional = (func.im_self,) + positional + num_pos = len(positional) + num_total = num_pos + len(named) + num_args = len(args) + num_defaults = len(defaults) if defaults else 0 + for arg, value in zip(args, positional): + assign(arg, value) + if varargs: + if num_pos > num_args: + assign(varargs, positional[-(num_pos-num_args):]) + else: + assign(varargs, ()) + elif 0 < num_args < num_pos: + raise TypeError('%s() takes %s %d %s (%d given)' % ( + f_name, 'at most' if defaults else 'exactly', num_args, + 'arguments' if num_args > 1 else 'argument', num_total)) + elif num_args == 0 and num_total: + raise TypeError('%s() takes no arguments (%d given)' % + (f_name, num_total)) + for arg in args: + if isinstance(arg, str) and arg in named: + if is_assigned(arg): + raise TypeError("%s() got multiple values for keyword " + "argument '%s'" % (f_name, arg)) + else: + assign(arg, named.pop(arg)) + if defaults: # fill in any missing values with the defaults + for arg, value in zip(args[-num_defaults:], defaults): + if not is_assigned(arg): + assign(arg, value) + if varkw: + assign(varkw, named) + elif named: + unexpected = next(iter(named)) + if isinstance(unexpected, unicode): + unexpected = unexpected.encode(sys.getdefaultencoding(), 'replace') + raise TypeError("%s() got an unexpected keyword argument '%s'" % + (f_name, unexpected)) + unassigned = num_args - len([arg for arg in args if is_assigned(arg)]) + if unassigned: + num_required = num_args - num_defaults + raise TypeError('%s() takes %s %d %s (%d given)' % ( + f_name, 'at least' if defaults else 'exactly', num_required, + 'arguments' if num_required > 1 else 'argument', num_total)) + return arg2value + # -------------------------------------------------- stack frame extraction Traceback = namedtuple('Traceback', 'filename lineno function code_context index') diff --git a/Lib/test/test_inspect.py b/Lib/test/test_inspect.py index 389dae761c5..a2913e8cafc 100644 --- a/Lib/test/test_inspect.py +++ b/Lib/test/test_inspect.py @@ -1,8 +1,11 @@ +import re import sys import types import unittest import inspect import datetime +from UserList import UserList +from UserDict import UserDict from test.test_support import run_unittest, check_py3k_warnings @@ -557,10 +560,197 @@ class TestClassesAndFunctions(unittest.TestCase): self.assertIn(('m1', 'method', D), attrs, 'missing plain method') self.assertIn(('datablob', 'data', A), attrs, 'missing data') +class TestGetcallargsFunctions(unittest.TestCase): + + # tuple parameters are named '.1', '.2', etc. + is_tuplename = re.compile(r'^\.\d+$').match + + def assertEqualCallArgs(self, func, call_params_string, locs=None): + locs = dict(locs or {}, func=func) + r1 = eval('func(%s)' % call_params_string, None, locs) + r2 = eval('inspect.getcallargs(func, %s)' % call_params_string, None, + locs) + self.assertEqual(r1, r2) + + def assertEqualException(self, func, call_param_string, locs=None): + locs = dict(locs or {}, func=func) + try: + eval('func(%s)' % call_param_string, None, locs) + except Exception, ex1: + pass + else: + self.fail('Exception not raised') + try: + eval('inspect.getcallargs(func, %s)' % call_param_string, None, + locs) + except Exception, ex2: + pass + else: + self.fail('Exception not raised') + self.assertIs(type(ex1), type(ex2)) + self.assertEqual(str(ex1), str(ex2)) + + def makeCallable(self, signature): + """Create a function that returns its locals(), excluding the + autogenerated '.1', '.2', etc. tuple param names (if any).""" + code = ("lambda %s: dict(i for i in locals().items() " + "if not is_tuplename(i[0]))") + return eval(code % signature, {'is_tuplename' : self.is_tuplename}) + + def test_plain(self): + f = self.makeCallable('a, b=1') + self.assertEqualCallArgs(f, '2') + self.assertEqualCallArgs(f, '2, 3') + self.assertEqualCallArgs(f, 'a=2') + self.assertEqualCallArgs(f, 'b=3, a=2') + self.assertEqualCallArgs(f, '2, b=3') + # expand *iterable / **mapping + self.assertEqualCallArgs(f, '*(2,)') + self.assertEqualCallArgs(f, '*[2]') + self.assertEqualCallArgs(f, '*(2, 3)') + self.assertEqualCallArgs(f, '*[2, 3]') + self.assertEqualCallArgs(f, '**{"a":2}') + self.assertEqualCallArgs(f, 'b=3, **{"a":2}') + self.assertEqualCallArgs(f, '2, **{"b":3}') + self.assertEqualCallArgs(f, '**{"b":3, "a":2}') + # expand UserList / UserDict + self.assertEqualCallArgs(f, '*UserList([2])') + self.assertEqualCallArgs(f, '*UserList([2, 3])') + self.assertEqualCallArgs(f, '**UserDict(a=2)') + self.assertEqualCallArgs(f, '2, **UserDict(b=3)') + self.assertEqualCallArgs(f, 'b=2, **UserDict(a=3)') + # unicode keyword args + self.assertEqualCallArgs(f, '**{u"a":2}') + self.assertEqualCallArgs(f, 'b=3, **{u"a":2}') + self.assertEqualCallArgs(f, '2, **{u"b":3}') + self.assertEqualCallArgs(f, '**{u"b":3, u"a":2}') + + def test_varargs(self): + f = self.makeCallable('a, b=1, *c') + self.assertEqualCallArgs(f, '2') + self.assertEqualCallArgs(f, '2, 3') + self.assertEqualCallArgs(f, '2, 3, 4') + self.assertEqualCallArgs(f, '*(2,3,4)') + self.assertEqualCallArgs(f, '2, *[3,4]') + self.assertEqualCallArgs(f, '2, 3, *UserList([4])') + + def test_varkw(self): + f = self.makeCallable('a, b=1, **c') + self.assertEqualCallArgs(f, 'a=2') + self.assertEqualCallArgs(f, '2, b=3, c=4') + self.assertEqualCallArgs(f, 'b=3, a=2, c=4') + self.assertEqualCallArgs(f, 'c=4, **{"a":2, "b":3}') + self.assertEqualCallArgs(f, '2, c=4, **{"b":3}') + self.assertEqualCallArgs(f, 'b=2, **{"a":3, "c":4}') + self.assertEqualCallArgs(f, '**UserDict(a=2, b=3, c=4)') + self.assertEqualCallArgs(f, '2, c=4, **UserDict(b=3)') + self.assertEqualCallArgs(f, 'b=2, **UserDict(a=3, c=4)') + # unicode keyword args + self.assertEqualCallArgs(f, 'c=4, **{u"a":2, u"b":3}') + self.assertEqualCallArgs(f, '2, c=4, **{u"b":3}') + self.assertEqualCallArgs(f, 'b=2, **{u"a":3, u"c":4}') + + def test_tupleargs(self): + f = self.makeCallable('(b,c), (d,(e,f))=(0,[1,2])') + self.assertEqualCallArgs(f, '(2,3)') + self.assertEqualCallArgs(f, '[2,3]') + self.assertEqualCallArgs(f, 'UserList([2,3])') + self.assertEqualCallArgs(f, '(2,3), (4,(5,6))') + self.assertEqualCallArgs(f, '(2,3), (4,[5,6])') + self.assertEqualCallArgs(f, '(2,3), [4,UserList([5,6])]') + + def test_multiple_features(self): + f = self.makeCallable('a, b=2, (c,(d,e))=(3,[4,5]), *f, **g') + self.assertEqualCallArgs(f, '2, 3, (4,[5,6]), 7') + self.assertEqualCallArgs(f, '2, 3, *[(4,[5,6]), 7], x=8') + self.assertEqualCallArgs(f, '2, 3, x=8, *[(4,[5,6]), 7]') + self.assertEqualCallArgs(f, '2, x=8, *[3, (4,[5,6]), 7], y=9') + self.assertEqualCallArgs(f, 'x=8, *[2, 3, (4,[5,6])], y=9') + self.assertEqualCallArgs(f, 'x=8, *UserList([2, 3, (4,[5,6])]), ' + '**{"y":9, "z":10}') + self.assertEqualCallArgs(f, '2, x=8, *UserList([3, (4,[5,6])]), ' + '**UserDict(y=9, z=10)') + + def test_errors(self): + f0 = self.makeCallable('') + f1 = self.makeCallable('a, b') + f2 = self.makeCallable('a, b=1') + # f0 takes no arguments + self.assertEqualException(f0, '1') + self.assertEqualException(f0, 'x=1') + self.assertEqualException(f0, '1,x=1') + # f1 takes exactly 2 arguments + self.assertEqualException(f1, '') + self.assertEqualException(f1, '1') + self.assertEqualException(f1, 'a=2') + self.assertEqualException(f1, 'b=3') + # f2 takes at least 1 argument + self.assertEqualException(f2, '') + self.assertEqualException(f2, 'b=3') + for f in f1, f2: + # f1/f2 takes exactly/at most 2 arguments + self.assertEqualException(f, '2, 3, 4') + self.assertEqualException(f, '1, 2, 3, a=1') + self.assertEqualException(f, '2, 3, 4, c=5') + self.assertEqualException(f, '2, 3, 4, a=1, c=5') + # f got an unexpected keyword argument + self.assertEqualException(f, 'c=2') + self.assertEqualException(f, '2, c=3') + self.assertEqualException(f, '2, 3, c=4') + self.assertEqualException(f, '2, c=4, b=3') + self.assertEqualException(f, '**{u"\u03c0\u03b9": 4}') + # f got multiple values for keyword argument + self.assertEqualException(f, '1, a=2') + self.assertEqualException(f, '1, **{"a":2}') + self.assertEqualException(f, '1, 2, b=3') + # XXX: Python inconsistency + # - for functions and bound methods: unexpected keyword 'c' + # - for unbound methods: multiple values for keyword 'a' + #self.assertEqualException(f, '1, c=3, a=2') + f = self.makeCallable('(a,b)=(0,1)') + self.assertEqualException(f, '1') + self.assertEqualException(f, '[1]') + self.assertEqualException(f, '(1,2,3)') + +class TestGetcallargsMethods(TestGetcallargsFunctions): + + def setUp(self): + class Foo(object): + pass + self.cls = Foo + self.inst = Foo() + + def makeCallable(self, signature): + assert 'self' not in signature + mk = super(TestGetcallargsMethods, self).makeCallable + self.cls.method = mk('self, ' + signature) + return self.inst.method + +class TestGetcallargsUnboundMethods(TestGetcallargsMethods): + + def makeCallable(self, signature): + super(TestGetcallargsUnboundMethods, self).makeCallable(signature) + return self.cls.method + + def assertEqualCallArgs(self, func, call_params_string, locs=None): + return super(TestGetcallargsUnboundMethods, self).assertEqualCallArgs( + *self._getAssertEqualParams(func, call_params_string, locs)) + + def assertEqualException(self, func, call_params_string, locs=None): + return super(TestGetcallargsUnboundMethods, self).assertEqualException( + *self._getAssertEqualParams(func, call_params_string, locs)) + + def _getAssertEqualParams(self, func, call_params_string, locs=None): + assert 'inst' not in call_params_string + locs = dict(locs or {}, inst=self.inst) + return (func, 'inst,' + call_params_string, locs) + def test_main(): - run_unittest(TestDecorators, TestRetrievingSourceCode, TestOneliners, - TestBuggyCases, - TestInterpreterStack, TestClassesAndFunctions, TestPredicates) + run_unittest( + TestDecorators, TestRetrievingSourceCode, TestOneliners, TestBuggyCases, + TestInterpreterStack, TestClassesAndFunctions, TestPredicates, + TestGetcallargsFunctions, TestGetcallargsMethods, + TestGetcallargsUnboundMethods) if __name__ == "__main__": test_main() diff --git a/Misc/NEWS b/Misc/NEWS index 35108f48474..5752318007c 100644 --- a/Misc/NEWS +++ b/Misc/NEWS @@ -32,6 +32,9 @@ Core and Builtins Library ------- +- Issue #3135: Add inspect.getcallargs, which binds arguments to a function like + a normal call. + - Backwards incompatible change: Unicode codepoints line tabulation (0x0B) and form feed (0x0C) are now considered linebreaks, as specified in Unicode Standard Annex #14. See issue #7643.