6

I'm wondering what the story -- whether sound design or inherited legacy -- is behind these functools.partial and inspect.signature facts (talking python 3.8 here).

Set up:

from functools import partial
from inspect import signature

def bar(a, b):
    return a / b

All starts well with the following, which seems compliant with curry-standards. We're fixing a to 3 positionally, a disappears from the signature and it's value is indeed bound to 3:

f = partial(bar, 3)
assert str(signature(f)) == '(b)'
assert f(6) == 0.5 == f(b=6)

If we try to specify an alternate value for a, f won't tell us that we got an unexpected keyword, but rather that it got multiple values for argument a:

f(a=2, b=6)  # TypeError: bar() got multiple values for argument 'a'
f(c=2, b=6)  # TypeError: bar() got an unexpected keyword argument 'c'

But now if we fix b=3 through a keyword, b is not removed from the signature, it's kind changes to keyword-only, and we can still use it (overwrite the default, as a normal default, which we couldn't do with a in the previous case):

f = partial(bar, b=3)
assert str(signature(f)) == '(a, *, b=3)'
assert f(6) == 2.0 == f(6, b=3)
assert f(6, b=1) == 6.0

Why such asymmetry?

It gets even stranger, we can do this:

f = partial(bar, a=3)
assert str(signature(f)) == '(*, a=3, b)'  # whaaa?! non-default argument follows default argument?

Fine: For keyword-only arguments, there can be no confusing of what parameter a default is assigned to, but I still wonder what design-thinking or constraints are behind these choices.

thorwhalen
  • 1,920
  • 14
  • 26

2 Answers2

5

Using partial with a Positional Argument

f = partial(bar, 3)

By design, upon calling a function, positional arguments are assigned first. Then logically, 3 should be assigned to a with partial. It makes sense to remove it from the signature as there is no way to assign anything to it again!

when you have f(a=2, b=6), you are actually doing

bar(3, a=2, b=6)

when you have f(2, 2), you are actually doing

bar (3, 2, 2)

We never get rid of 3

For the new partial function:

  1. We can't give a a different value with another positional argument
  2. We can't use the keyword a to assign a different value to it as it is already "filled"

If there is a parameter with the same name as the keyword, then the argument value is assigned to that parameter slot. However, if the parameter slot is already filled, then that is an error.

I recommend reading the function calling behavior section of pep-3102 to get a better grasp of this matter.

Using partial with a Keyword Argument

f = partial(bar, b=3)

This is a different use case. We are applying a keyword argument to bar.

You are functionally turning

def bar(a, b):
    ...

into

def f(a, *, b=3):
    ...

where b becomes a keyword-only argument instead of

def f(a, b=3):
    ...

inspect.signature correctly reflects a design decision of partial. The keyword arguments passed to partial are designed to append additional positional arguments (source).

Note that this behavior does not necessarily override the keyword arguments supplied with f = partial(bar, b=3), i.e., b=3 will be applied regardless of whether you supply the second positional argument or not (and there will be a TypeError if you do so). This is different from a positional argument with a default value.

>>> f(1, 2)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: f() takes 1 positional argument but 2 were given

where f(1, 2) is equivalent to bar(1, 2, b=3)

The only way to override it is with a keyword argument

>>> f(2, b=2)

An argument that can only be assigned with a keyword but positionally? This is a keyword-only argument. Thus (a, *, b=3) instead of (a, b=3).

The Rationale of Non-default Argument follows Default Argument

f = partial(bar, a=3)
assert str(signature(f)) == '(*, a=3, b)'  # whaaa?! non-default argument follows default argument?
  1. You can't do def bar(a=3, b). a and b are so called positional-or-keyword arguments.
  2. You can do def bar(*, a=3, b). a and b are keyword-only arguments.

Even though semantically, a has a default value and thus it is optional, we can't leave it unassigned because b, which is a positional-or-keyword argument needs to be assigned a value if we want to use b positionally. If we do not supply a value for a, we have to use b as a keyword argument.

Checkmate! There is no way for b to be a positional-or-keyword argument as we intended.

The PEP for positonal-only arguments also kind of shows the rationale behind it.

This also has something to do with the aforementioned "function calling behavior".

partial != Currying & Implementation Details

partial by its implementation wraps the original function while storing the fixed arguments you passed to it.

IT IS NOT IMPLEMENTED WITH CURRYING. It is rather partial application instead of currying in the sense of functional programming. partial is essentially applying the fixed arguments first, then the arguments you called with the wrapper:

def __call__(self, /, *args, **keywords):
    keywords = {**self.keywords, **keywords}
    return self.func(*self.args, *args, **keywords)

This explains f(a=2, b=6) # TypeError: bar() got multiple values for argument 'a'.

See also: Why is partial called partial instead of curry

Under the Hood of inspect

The outputs of inspect is another story.

inspect itself is a tool that produces user-friendly outputs. For partial() in particular (and partialmethod(), similarly), it follows the wrapped function while taking the fixed parameters into account:

if isinstance(obj, functools.partial):
    wrapped_sig = _get_signature_of(obj.func)
    return _signature_get_partial(wrapped_sig, obj)

Do note that it is not inspect.signature's goal to show you the actual signature of the wrapped function in the AST.

def _signature_get_partial(wrapped_sig, partial, extra_args=()):
    """Private helper to calculate how 'wrapped_sig' signature will
    look like after applying a 'functools.partial' object (or alike)
    on it.
    """
    ...

So we have a nice and ideal signature for f = partial(bar, 3) but get f(a=2, b=6) # TypeError: bar() got multiple values for argument 'a' in reality.

Follow-up

If you want currying so badly, how do you implement it in Python, in the way which gives you the expected TypeError?

PIG208
  • 2,060
  • 2
  • 10
  • 25
  • I accepted this as the best answer. I would note though that there is a way for `b` to be position only by allowing the wrapper to change parameter order so that `(b, *, a=3)`. It might seem dark to do so as knee-reaction, but it does make sense sometimes. For example, if the wrapped is truly just an internal "core" that is meant to be make context-dependent interfaces. – thorwhalen Mar 14 '22 at 19:23
4

When you provide positional or keyword arguments to partial, the new function is constructed

f = partial(bar, 3)
f(a=2, b=6) # TypeError: bar() got multiple values for argument 'a'
f(c=2, b=6) # TypeError: bar() got an unexpected keyword argument 'c'

This is actually consistent with the idea of partial, which is that arguments are passed to the wrapped function with the addition of positional and keyword arguments passed to partial

These cases behave as expected:

bar(3, a=2, b=6)  # TypeError: bar() got multiple values for argument 'a'
bar(3, c=2, b=6)  # TypeError: bar() got an unexpected keyword argument 'c'

But now if we fix b=3 through a keyword, b is not removed from the signature,
f = partial(bar, b=3)
assert str(signature(f)) == '(a, *, b=3)'
assert f(6) == 2.0 == f(6, b=3)
assert f(6, b=1) == 6.0

This case is different from the above because in the previous case, a positional argument was provided to partial, not a keyword argument. When positional arguments are provided to partial, then it makes sense to remove them from the signature. Arguments provided as keywords are not removed from the signature.

So far, there is no inconsistency or asymmetry.

f = partial(bar, a=3)
assert str(signature(f)) == '(*, a=3, b)' # whaaa?! non-default argument follows default argument?

The signature here makes sense and is the expectation for partial(bar, a=3) -- it works the same as def f(*, a=3, b): ... and is the correct signature in this case. Note that when you provide a=3 to partial in this case, a becomes a keyword-only argument, as does b.

This is because when a positional argument is provided as a keyword, all following arguments must be specified keyword arguments.

sig = signature(f)
sig.parameters['a'].kind  # <_ParameterKind.KEYWORD_ONLY: 3>
inspect.getfullargspec(f)
# FullArgSpec(args=[], varargs=None, varkw=None, defaults=None, kwonlyargs=['a', 'b'], kwonlydefaults={'a': 3}, annotations={})
sytech
  • 29,298
  • 3
  • 45
  • 86