From 136a69b472c48e9b696c1fe1a0fd481daa7775b3 Mon Sep 17 00:00:00 2001 From: Graham Dumpleton Date: Sat, 24 Aug 2013 22:39:02 +1000 Subject: [PATCH] Replace decorator parameter validation with variant of code from inspect.getcallargs() from Python 3. --- src/__init__.py | 1 - src/decorators.py | 187 ++++++++++++++++++++++++++++----------- src/exceptions.py | 2 - tests/test_decorators.py | 12 ++- 4 files changed, 142 insertions(+), 60 deletions(-) delete mode 100644 src/exceptions.py diff --git a/src/__init__.py b/src/__init__.py index 785df2d..ca76169 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -3,4 +3,3 @@ __version__ = '.'.join(__version_info__) from .wrappers import ObjectProxy, FunctionWrapper from .decorators import decorator, adapter -from .exceptions import (MissingParameter, UnexpectedParameters) diff --git a/src/decorators.py b/src/decorators.py index fe056d3..92c1f4d 100644 --- a/src/decorators.py +++ b/src/decorators.py @@ -3,10 +3,135 @@ """ from functools import wraps, partial -from inspect import getargspec +from inspect import getargspec, ismethod +from collections import namedtuple from .wrappers import FunctionWrapper -from .exceptions import (MissingParameter, UnexpectedParameters) + +from . import six + +# We must use inspect.getfullargspec() in Python 3 because of the +# possibility that decorators can be applied to functions with keyword +# only arguments. Create a version of getfulargspec() for Python 2 so we +# work against the same specification structure. + +if six.PY2: + FullArgSpec = namedtuple('FullArgSpec', 'args, varargs, varkw, ' \ + 'defaults, kwonlyargs, kwonlydefaults, annotations') + + def getfullargspec(func): + argspec = getargspec(func) + return FullArgSpec(args=argspec.args, varargs=argspec.varargs, + varkw=argspec.keywords, defaults=argspec.defaults, + kwonlyargs=[], kwonlydefaults=None, annotations={}) + +else: + from inspect import getfullargspec + +# Python 3 provides getcallargs() that could be used to validate the +# decorator parameters, but it would not be available in Python 2. Plus, +# we need to fiddle the result from getfullargspec() a bit as we are +# going to be using the argspec from the wrapper and not the decorator, +# so it has our standard wrapper arguments before what would be the +# decorator arguments. So we duplicate the code from Python 3 and tweak +# it a bit. + +def _missing_arguments(f_name, argnames, pos, values): + names = [repr(name) for name in argnames if name not in values] + missing = len(names) + if missing == 1: + s = names[0] + elif missing == 2: + s = "{} and {}".format(*names) + else: + tail = ", {} and {}".format(names[-2:]) + del names[-2:] + s = ", ".join(names) + tail + raise TypeError("%s() missing %i required %s argument%s: %s" % + (f_name, missing, + "positional" if pos else "keyword-only", + "" if missing == 1 else "s", s)) + +def _too_many(f_name, args, kwonly, varargs, defcount, given, values): + atleast = len(args) - defcount + kwonly_given = len([arg for arg in kwonly if arg in values]) + if varargs: + plural = atleast != 1 + sig = "at least %d" % (atleast,) + elif defcount: + plural = True + sig = "from %d to %d" % (atleast, len(args)) + else: + plural = len(args) != 1 + sig = str(len(args)) + kwonly_sig = "" + if kwonly_given: + msg = " positional argument%s (and %d keyword-only argument%s)" + kwonly_sig = (msg % ("s" if given != 1 else "", kwonly_given, + "s" if kwonly_given != 1 else "")) + raise TypeError("%s() takes %s positional argument%s but %d%s %s given" % + (f_name, sig, "s" if plural else "", given, kwonly_sig, + "was" if given == 1 and not kwonly_given else "were")) + +def _validate_parameters(func, spec, *positional, **named): + args, varargs, varkw, defaults, kwonlyargs, kwonlydefaults, ann = spec + + # We throw away the initial leading arguments as they are our + # special wrapper arguments and not part of the arguments which + # become the decorator arguments. + + args = args[len(WRAPPER_ARGLIST):] + + f_name = func.__name__ + arg2value = {} + + if ismethod(func) and func.__self__ is not None: + # implicit 'self' (or 'cls' for classmethods) argument + positional = (func.__self__,) + positional + num_pos = len(positional) + num_args = len(args) + num_defaults = len(defaults) if defaults else 0 + + n = min(num_pos, num_args) + for i in range(n): + arg2value[args[i]] = positional[i] + if varargs: + arg2value[varargs] = tuple(positional[n:]) + possible_kwargs = set(args + kwonlyargs) + if varkw: + arg2value[varkw] = {} + for kw, value in named.items(): + if kw not in possible_kwargs: + if not varkw: + raise TypeError("%s() got an unexpected keyword argument %r" % + (f_name, kw)) + arg2value[varkw][kw] = value + continue + if kw in arg2value: + raise TypeError("%s() got multiple values for argument %r" % + (f_name, kw)) + arg2value[kw] = value + if num_pos > num_args and not varargs: + _too_many(f_name, args, kwonlyargs, varargs, num_defaults, + num_pos, arg2value) + if num_pos < num_args: + req = args[:num_args - num_defaults] + for arg in req: + if arg not in arg2value: + _missing_arguments(f_name, req, True, arg2value) + for i, arg in enumerate(args[num_args - num_defaults:]): + if arg not in arg2value: + arg2value[arg] = defaults[i] + missing = 0 + for kwarg in kwonlyargs: + if kwarg not in arg2value: + if kwarg in kwonlydefaults: + arg2value[kwarg] = kwonlydefaults[kwarg] + else: + missing += 1 + if missing: + _missing_arguments(f_name, kwonlyargs, False, arg2value) + return arg2value # Copy name attributes from a wrapped function onto an wrapper. This is # only used in mapping the name from the final wrapped function to which @@ -54,64 +179,26 @@ def decorator(wrapper=None, target=None): # supplied, they can be as either positional or keyword # arguments. - wrapper_argspec = getargspec(wrapper) - + wrapper_argspec = getfullargspec(wrapper) wrapper_arglist = wrapper_argspec.args - wrapper_defaults = (wrapper_argspec.defaults and - wrapper_arglist[-len(wrapper_argspec.defaults):] or []) if (len(wrapper_arglist) > len(WRAPPER_ARGLIST) or - wrapper_argspec.varargs or wrapper_argspec.keywords): + wrapper_argspec.varargs or wrapper_argspec.varkw or + wrapper_argspec.kwonlyargs): + # For the case where the user decorator is able to accept # parameters, return a partial wrapper to collect the # parameters. @wraps(wrapper) def _partial(*decorator_args, **decorator_kwargs): - # We need to construct a final set of parameters - # by merging positional parameters with the - # keyword parameters. This allows us to pass just - # one dictionary of parameters. We can also - # validate the set of parameters at this point so - # that an error occurs when the decorator is used - # and not only let it fail at the time the - # wrapped function is called. + # Validate the set of decorator parameters at + # this point so that an error occurs when the + # decorator is used and not only when the + # function is later called. - expected_names = wrapper_arglist[len(WRAPPER_ARGLIST):] - - if len(decorator_args) > len(expected_names): - if not wrapper_argspec.varargs: - raise UnexpectedParameters('Expected at most %r ' - 'positional parameters for decorator %r, ' - 'but received %r.' % (len(expected_names), - wrapper.__name__, len(decorator_args))) - - unexpected_params = [] - for name in decorator_kwargs: - if name not in expected_names: - unexpected_params.append(name) - - if unexpected_params: - if not wrapper_argspec.keywords: - raise UnexpectedParameters('Unexpected parameters ' - '%r supplied for decorator %r.' % ( - unexpected_params, wrapper.__name__)) - - received_names = set(wrapper_defaults) - for i in range(min(len(decorator_args), len(expected_names))): - if expected_names[i] in decorator_kwargs: - raise UnexpectedParameters('Positional parameter ' - '%r also supplied as keyword parameter ' - 'to decorator %r.' % (expected_names[i], - wrapper.__name__)) - received_names.add(expected_names[i]) - - received_names.update(decorator_kwargs.keys()) - for name in expected_names: - if name not in received_names: - raise MissingParameter('Expected value for ' - 'parameter %r was not supplied for ' - 'decorator %r.' % (name, wrapper.__name__)) + _validate_parameters(wrapper, wrapper_argspec, + *decorator_args, **decorator_kwargs) # Now create and return the final wrapper which # combines the parameters with the wrapped function. diff --git a/src/exceptions.py b/src/exceptions.py deleted file mode 100644 index 0ebc4b8..0000000 --- a/src/exceptions.py +++ /dev/null @@ -1,2 +0,0 @@ -class MissingParameter(TypeError): pass -class UnexpectedParameters(TypeError): pass diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 040099d..4bebc51 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -3,7 +3,6 @@ from __future__ import print_function import unittest import wrapt -import wrapt.exceptions class TestDecorator(unittest.TestCase): @@ -87,8 +86,7 @@ class TestDecorator(unittest.TestCase): def _function(*args, **kwargs): return args, kwargs - self.assertRaises(wrapt.exceptions.MissingParameter, - run, ()) + self.assertRaises(TypeError, run, ()) def test_unexpected_parameters_one(self): def run(*args): @@ -100,7 +98,7 @@ class TestDecorator(unittest.TestCase): def _function(*args, **kwargs): return args, kwargs - self.assertRaises(wrapt.exceptions.UnexpectedParameters, run, ()) + self.assertRaises(TypeError, run, ()) def test_unexpected_parameters_many(self): def run(*args): @@ -112,7 +110,7 @@ class TestDecorator(unittest.TestCase): def _function(*args, **kwargs): return args, kwargs - self.assertRaises(wrapt.exceptions.UnexpectedParameters, run, ()) + self.assertRaises(TypeError, run, ()) def test_override_parameters_positional_all(self): _args = (1, 2) @@ -178,7 +176,7 @@ class TestDecorator(unittest.TestCase): def _function(*args, **kwargs): return args, kwargs - self.assertRaises(wrapt.exceptions.UnexpectedParameters, run, ()) + self.assertRaises(TypeError, run, ()) def test_override_parameters_positional_excess_many(self): def run(*args): @@ -190,7 +188,7 @@ class TestDecorator(unittest.TestCase): def _function(*args, **kwargs): return args, kwargs - self.assertRaises(wrapt.exceptions.UnexpectedParameters, run, ()) + self.assertRaises(TypeError, run, ()) def test_varargs_parameters(self): _args = (1, 2)