This is a decorator that makes some of a function's or method's arguments into keyword-only arguments. As much as it possible, it emulates the Python 3 handling of keyword-only arguments, including messages for TypeErrors. It's compatible with Python 3 so it can be used in code bases that support both versions.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | #!/usr/bin/python
from __future__ import print_function
import functools
import inspect
def decorator_factory(*kw_only_parameters):
"""Transforms a function with keyword arguments into one with
keyword-only arguments.
Call this decorator as @decorator_factory() for the default mode,
which makes all keyword arguments keyword-only, or with the names
of arguments to make keyword-only. They must correspond with the
names of arguments in the decorated function. It works by
collecting all the arguments into *args and **kws, then moving the
arguments marked as keyword-only from **kws into *args.
Args:
*kw_only_parameters: Keyword-only arguments as strings.
Returns:
A decorator that modifies a function so it has keyword-only
arguments.
"""
def decorator(wrapped):
"""The decorator itself, assigns arguments as keyword-only and
calculates sets for error checking.
Args:
wrapped: The function to decorate.
Returns:
A function wrapped so that it has keyword-only arguments.
"""
# Each Python 3 argument has two independent properties: it is
# positional-and-keyword *or* keyword-only, and it has a
# default value or it doesn't.
names, varargs, _, defaults = inspect.getargspec(wrapped)
# If there are no default values getargpsec() returns None
# rather than an empty iterable for some reason.
if defaults is None:
defaults = ()
names_with_defaults = frozenset(names[len(names) - len(defaults):])
names_to_defaults = dict(zip(reversed(names), reversed(defaults)))
if kw_only_parameters:
kw_only_names = frozenset(kw_only_parameters)
else:
kw_only_names = names_with_defaults.copy()
@functools.wraps(wrapped)
def wrapper(*args, **kws):
"""Wrapper function, checks arguments with set operations, moves args
from **kws into *args, and then calls wrapped().
Args:
*args, **kws: The arguments passed to the original function.
Returns:
The original function's result when it's called with the
modified arguments.
Raises:
TypeError: When there is a mismatch between the supplied
and expected arguments.
"""
new_args = []
args_index = 0
for name in names:
if name in kws:
# Check first if there's a bound keyword for this name
new_args.append(kws.pop(name))
elif name in kw_only_names:
# If this name is keyword-only, check for a
# default or raise.
if name in names_to_defaults:
new_args.append(names_to_defaults[name])
else:
_wrong_args(wrapped, names,
kw_only_names -
(names_with_defaults | frozenset(kws)),
'keyword-only')
elif args_index < len(args):
# Check for a positional arg.
new_args.append(args[args_index])
args_index += 1
elif name in names_to_defaults:
# Check for a default value.
new_args.append(names_to_defaults[name])
else:
# No positional arg or default for this name so raise.
_wrong_args(wrapped, names,
frozenset(names) -
(names_with_defaults | frozenset(kws)),
'positional', len(args))
if args_index != len(args) and not varargs:
# Too many positional arguments and no varargs, so
# raise after subtracting off the number of kw-only
# arguments from those expected.
raise TypeError(
'%s() takes %d positional arguments but %d were given' %
(wrapped.__name__,
len(names) - len(names_with_defaults | frozenset(kws)),
len(args)))
else:
# Pass the rest of the positional args, if any.
new_args.extend(args[args_index:])
return wrapped(*new_args, **kws)
return wrapper
return decorator
def _wrong_args(wrapped, names, missing_args, arg_type, number_of_args=0):
""" Raise Python 3-style TypeErrors for missing arguments."""
ordered_args = [a for a in names if a in missing_args]
ordered_args = ordered_args[number_of_args:]
error_message = ['%s() missing %d required %s argument' %
(wrapped.__name__, len(ordered_args), arg_type)]
if len(ordered_args) == 1:
error_message.append(": '%s'" % ordered_args[0])
else:
error_message.extend(['s: ',
' '.join("'%s'" % a for a in ordered_args[:-1]),
" and '%s'" % ordered_args[-1]])
raise TypeError(''.join(error_message))
if __name__ == '__main__':
def test(f, *args, **kws):
print(args, kws, '-> ', end='')
try:
f(*args, **kws)
except TypeError as e:
print(e.args[0])
@decorator_factory()
def f(a, b, c, d, e='e'):
print(a, b, c, d, e)
test(f)
test(f, 0, 1, 2, 3)
test(f, 0, 1, 2, 3, 4)
@decorator_factory('c')
def f(a, b, c='c', d='d', *args, **kws):
print(a, b, c, d, args, kws)
test(f)
test(f, 0, 1)
test(f, -1, b='b')
test(f, b='b')
test(f, 0)
test(f, 0, 1, 2, 3, 4, 5, c='foo', d='bar', e='baz')
@decorator_factory('b', 'c')
def f(a, b, c='c', d='d', *args, **kws):
print(a, b, c, d, args, kws)
test(f, 0)
test(f, 0, b='b')
test(f, 0, 1, 2)
test(f, 0, 1, 2, b='b')
test(f, 0, 1, 2, 3, 4, 5, c='foo', d='bar', e='baz')
test(f, 0, 1, 2, 3, 4, 5, b='foo', c='bar', e='baz')
class C(object):
@decorator_factory('b', 'c')
def __init__(self, a, b, c='c', d='d', *args, **kws):
print(a, b, c, d, args, kws)
test(C, 0, 1, 2, b='b')
test(C, 0, 1, 2, 3, 4, 5, b='foo', c='bar', e='baz')
|
Some simplifications.
Great Recipe! One potential bug I found. line 42:
I believe should be:
Your tests worked because you always had the same number of positional args w/o defaults as positional args w/ defaults. Change this around and you'll see the difference.
I was ready to take a project of mine to Python 3 only because I absolutely wanted to require certain args to be keyword only. Now I can continue Py2.7 support for a bit longer.
I needed to make a few more changes to have it work in every situation in Python 2 and 3; probably too many changes to incorporate easily, but it might be useful to someone:
I'm getting back to this after being busy with some other projects. I'd like to update this to incorporate your bugfixes, but I'm not sure I understand what some of your code is doing. I see your point about the length of positional args and the need for a placeholder for args without defaults, but I'm not sure what some of your other changes are for.
The latest revision should fix Michael Cuthbert's bugs as well as some other bugs I found.