pythonfunctionparameterspython-3.7

How can I enforce some function parameters to be only positional only


I want to mimic this behaviour of Python3.8 in Python3.7

Positional-only parameters / was the syntax introduced to indicate that some function parameters must be specified positionally and cannot be used as keyword arguments.

#Python3.8
def f(a,b,/,**kwargs):
    print(a,b,kwargs)

>>> f(1,2,**{'a':100,'b':200,'c':300})
# 1 2 {'a': 100, 'b': 200, 'c': 300}

a,b are used only as positional parameters.

How I do the same in Python3.7

#Python3.7

def f(a,b,**kwargs):
    print(a,b,kwargs)

>>> f(1,2,**{'a':1,'b':2})
# TypeError: f() got multiple values for argument 'a'

How do I make a,b to be only positional parameters. / doesn't work below from Python3.8

Is it possible to mimic / syntax in Python3.7?


Solution

  • You can create a custom decorator that declares the positional-only arguments, returning a wrapper that parses its own *args, **kwargs such that they fit the signature of the decorated function. Due to possible name clashes between positional-only and keyword arguments, it is not possible to use keyword-argument-packing (**) for this approach (this is the only limitation). Packed keyword arguments need to be declared either as the last positional-or-keyword parameter or as the first keyword-only parameter. Here are two examples:

    def foo(a, b, kwargs):  # last positional-or-keyword parameter
        pass
    
    def foo(a, *args, kwargs):  # first keyword-only parameter
        pass
    

    The variable kwargs will receive the remaining **kwargs from the wrapper function, i.e. it can be used similarly as if **kwargs had been used in the decorated function directly (like in Python 3.8+).

    The following implementation of the decorator is largely based on the implementation of inspect.Signature.bind with a few minor tweaks to handle positional-only parameters via the decorator-declared names and to handle the additional (artificial) kwargs parameter.

    import functools
    import inspect
    import itertools
    
    
    def positional_only(*names, kwargs_name='kwargs'):
        def decorator(func):
            signature = inspect.signature(func)
    
            @functools.wraps(func)
            def wrapper(*args, **kwargs):
                new_args = []
                new_kwargs = {}            
    
                parameters = iter(signature.parameters.values())
                parameters_ex = ()
                arg_vals = iter(args)
    
                while True:
                    try:
                        arg_val = next(arg_vals)
                    except StopIteration:
                        try:
                            param = next(parameters)
                        except StopIteration:
                            break
                        else:
                            if param.name == kwargs_name or param.kind == inspect.Parameter.VAR_POSITIONAL:
                                break
                            elif param.name in kwargs:
                                if param.name in names:
                                    msg = '{arg!r} parameter is positional only, but was passed as a keyword'
                                    msg = msg.format(arg=param.name)
                                    raise TypeError(msg) from None
                                parameters_ex = (param,)
                                break
                            elif param.default is not inspect.Parameter.empty:
                                parameters_ex = (param,)
                                break
                            else:
                                msg = 'missing a required argument: {arg!r}'
                                msg = msg.format(arg=param.name)
                                raise TypeError(msg) from None
                    else:
                        try:
                            param = next(parameters)
                        except StopIteration:
                            raise TypeError('too many positional arguments') from None
                        else:
                            if param.name == kwargs_name or param.kind == inspect.Parameter.KEYWORD_ONLY:
                                raise TypeError('too many positional arguments') from None
    
                            if param.kind == inspect.Parameter.VAR_POSITIONAL:
                                new_args.append(arg_val)
                                new_args.extend(arg_vals)
                                break
    
                            if param.name in kwargs and param.name not in names:
                                raise TypeError(
                                    'multiple values for argument {arg!r}'.format(
                                        arg=param.name)) from None
    
                            new_args.append(arg_val)
    
                for param in itertools.chain(parameters_ex, parameters):
                    if param.name == kwargs_name or param.kind == inspect.Parameter.VAR_POSITIONAL:
                        continue
    
                    try:
                        arg_val = kwargs.pop(param.name)
                    except KeyError:
                        if (param.kind != inspect.Parameter.VAR_POSITIONAL
                                and param.default is inspect.Parameter.empty):
                            raise TypeError(
                                'missing a required argument: {arg!r}'.format(
                                    arg=param.name)) from None
                    else:
                        if param.name in names:
                            raise TypeError(
                                '{arg!r} parameter is positional only, '
                                'but was passed as a keyword'.format(arg=param.name))
    
                        new_kwargs[param.name] = arg_val
    
                new_kwargs.update(kwargs=kwargs)
                return func(*new_args, **new_kwargs)
            return wrapper
        return decorator
    

    Here is an example of how it can be used:

    @positional_only('a')
    def foo(a, *args, kwargs, b=9, c):
        print(a, args, b, c, kwargs)
    
    foo(1, **dict(a=2), c=3)  # ok
    foo(1, 2, 3, 4, 5, c=6)  # ok
    foo(1, b=2, **dict(a=3), c=4)  # ok
    foo(a=1, c=2)  # error
    foo(c=1)  # error