Modifying the signature and defaults of a Python function, the hack-way

Viewed 318

I'm trying to understand the python data model better and ran into something odd.

def foo(a, b = 2):
    return a / b

assert foo(20) == 10.0

# note: for sanity purposes, should also change signature, but not needed for effect
foo.__defaults__ = (10,)
assert foo(20) == 2.0

foo.__defaults__ = ()
foo.__kwdefaults__ = {'b': 10}

foo(20)  # raises TypeError: foo() missing 1 required positional argument: 'b'

An error is expected: __kwdefaults__ is for keyword-only arguments, so let's make b a keyword-only argument to try to solve this problem:

from inspect import signature

foo.__signature__ = signature(lambda a, *, b=10: None)
foo(20)  # still raises TypeError: foo() missing 1 required positional argument: 'b'

How does the error message relate to what's happening?.

What I find strange is that neither the original function, nor my doctored one required b (it always had a default!). Also, b has never been a positional-only argument.

What is happening here? How can one transform foo to make b be a keyword-only argument with default 10.

If my original function had the signature I "injected" above, all goes well though:

def foo(a, *, b=2):  # same as previous `foo`, with signature we want
    return a / b

foo.__kwdefaults__ = {'b': 10}  # change kwdefault
assert foo(20) == 2.0  # it works!!

Preemptive note: I know of functools wraps and partial, which I could use -- though in my context, I'd rather change the function itself, not a wrapped version. My question is about the behavior I created in the code above: How did it come about?

1 Answers

Purpose of __signature__

Your issue is, that you think that you change a function's signature by setting foo.__signature__. However, this is not what's happening. It is equally useless to set it to foo.signature or foo.any_other_name. You just set a signature object to the respective property of the function, which changes nothing with regards to the function's behaviour. The only thing that __signature__ does is to change the behaviour of inspect.signature(), since it will return the signature of the function as stored in function.__signature__ iff it is set. I.e. the only thing, that __signature__ changes is the behaviour of inspect.signature(), but not the function itself. See ekhumoro's comment for the link to the appropriate PEP.

TypeError

As for the type error: In foo() b is not a kwarg-only argument:

def foo(a, b = 2):
    return a / b

It is a positional argument with a default value. Hence its default value is stored in foo.__defaults__. When you set foo.__defaults__ = () you erased those defaults. After that, b hence has no longer a default value and needs to be passed explicitly.

Changing signatures

How can one transform foo to make b be a keyword-only argument with default 10.

You cannot change a function's signature during runtime. Period.

Changing default values

You can, however, change b's default value to 10 via

>>> foo.__defaults__ = (10,)
>>> foo(2)
0.2

Since positional arguments with default values cannot be followed by positional arguments without defaults, the tuple __defaults__ is applied to the positional arguments from right to left.
So you can also give a a default value of e.g. 20 via

>>> foo.__defaults__ = (20, 10)
>>> foo()
2.0
Related