[Python-ideas] positional only arguments decorator

Steven Bethard steven.bethard at gmail.com
Mon May 21 20:30:38 CEST 2007


Ok, looks like there's not much chance of agreeing on a syntax, so
here's a decorator that covers the two use cases I know of::

* matching the signature of dict() and dict.update()
* allowing arguments to have their names changed without worrying
about backwards compatibility (e.g. ``def func(sequence)`` changing to
``def func(iterable)``)

I've pasted the docstring and code below.  The docstring should give
you a pretty good idea of the functionality.  Let me know if you have
use cases this wouldn't cover.

"""
Functions declared ad @positional_only prohibit any arguments from being
passed in as keyword arguments::

    >>> @positional_only
    ... def foo(a, b=None, *args):
    ...     return a, b, args
    ...
    >>> foo(1, 2, 3)
    (1, 2, (3,))
    >>> foo(a=1, b=2)
    Traceback (most recent call last):
      ...
    TypeError: foo() does not allow keyword arguments

Note that you can't use **kwargs arguments with a @positional_only
function::

    >>> @positional_only
    ... def foo(a, b=None, **kwargs):
    ...     pass
    ...
    Traceback (most recent call last):
      ...
    TypeError: foo() may not have **kwargs

If you need the functionality of **kwargs, you can request it by
adding a final argument with the name _kwargs. The keyword arguments
will be passed in (as a dict) through this argument::

    >>> @positional_only
    ... def foo(a, b=None, _kwargs=None):
    ...     return a, b, _kwargs
    ...
    >>> foo(1)
    (1, None, {})
    >>> foo(1, 2)
    (1, 2, {})
    >>> foo(1, bar=42, baz='spam')
    (1, None, {'baz': 'spam', 'bar': 42})

When your function is called with the wrong number of arguments, your
callers will only see the number of arguments they expected. That is,
argument counts will not include the _kwargs argument::

    >>> foo()
    Traceback (most recent call last):
      ...
    TypeError: foo() takes at least 1 positional argument(s) (0 given)
    >>> foo(1, 2, 3)
    Traceback (most recent call last):
      ...
    TypeError: foo() takes at most 2 positional argument(s) (3 given)

Note that unlike a normal **kwargs, using _kwargs in a
@positional_only function causes *all* keyword arguments to be
collected, even those with the same names as other function
parameters::

    >>> foo(1, a=42, b='spam')
    (1, None, {'a': 42, 'b': 'spam'})
    >>> foo(1, _kwargs='this is just silly')
    (1, None, {'_kwargs': 'this is just silly'})

This is of course because all function parameters are considered to
be positional only and therefore unnamed for the purposes of argument
parsing.

Note that you cannot use *args or **kwargs with the _kwargs argument::

    >>> @positional_only
    ... def foo(a, b, _kwargs, *args):
    ...     pass
    ...
    Traceback (most recent call last):
      ...
    TypeError: foo() may not have *args
    >>> @positional_only
    ... def foo(a, b, _kwargs, **kwargs):
    ...     pass
    ...
    Traceback (most recent call last):
      ...
    TypeError: foo() may not have **kwargs

"""

import doctest
import inspect
import functools

def positional_only(func):
    name = func.__name__

    # don't allow **kwargs
    arg_names, args_name, kwargs_name, _ = inspect.getargspec(func)
    if kwargs_name is not None:
        raise TypeError('%s() may not have **kwargs' % name)

    # if no keyword arguments were requested, create a wrapper that
    # only accepts positional arguments (as *args)
    if not arg_names or arg_names[-1] != '_kwargs':

        # wrapper that raises an error for **kwargs
        def positional_wrapper(*args, **kwargs):
            if kwargs:
                msg = '%s() does not allow keyword arguments'
                raise TypeError(msg % name)
            return func(*args)

    # if keyword arguments were requested (through a final _kwargs),
    # create a wrapper that collects **kwargs and passes them in as
    # the final positional argument
    else:

        # don't allow *args
        if args_name is not None:
            msg = '%s() may not have *args'
            raise TypeError(msg % func.__name__)

        # determine defaults and expected argument counts
        defaults = func.func_defaults
        defaults = defaults and defaults[:-1] or []
        n_expected = func.func_code.co_argcount - 1
        min_expected = n_expected - len(defaults)

        def positional_wrapper(*args, **kwargs):
            # raise a TypeError for wrong number of arguments here
            # because func() needs to take the extra 'kwargs'
            # argument but the caller shouldn't know it
            arg_count_err = None
            if len(args) < min_expected:
                arg_count_err = 'at least %i' % min_expected
            if len(args) > n_expected:
                arg_count_err = 'at most %i' % n_expected
            if arg_count_err is not None:
                msg = '%s() takes %s positional argument(s) (%i given)'
                raise TypeError(msg % (name, arg_count_err, len(args)))

            # fill in defaults and add the final _kwargs argument,
            # then call the function with only positional arguments
            n_missing = n_expected - len(args)
            args += func.func_defaults[-n_missing - 1:-1]
            args += (kwargs,)
            return func(*args)

    # return the wrapped function
    functools.update_wrapper(positional_wrapper, func)
    return positional_wrapper

if __name__ == '__main__':
    doctest.testmod()


STeVe
-- 
I'm not *in*-sane. Indeed, I am so far *out* of sane that you appear a
tiny blip on the distant coast of sanity.
        --- Bucky Katt, Get Fuzzy



More information about the Python-ideas mailing list