15

What's the proper type hint for functools.partial? I have a function that returns a partial and I want to type hint it so mypy doesn't throw any error:

def my_func() -> ?:
    return partial(foo, bar="baz")

More specific than typing.Callable

A23149577
  • 2,045
  • 2
  • 40
  • 74

1 Answers1

20

You have a couple of options here, depending on exactly what you're going for.

I'm going to assume for example that foo is defined

def foo(qux: int, thud: float, bar: str) -> str:
    # Does whatever
    return "Hi"

If we use reveal_type we find that partial(foo, bar="blah") is identified as functools.partial[builtins.str*]. That roughly translates to a function-ish thing which takes anything and returns a string. So, you could annotate it with exactly that, and you'd at least get the return type in your annotation.

def my_func() -> partial[str]:
    ...

a: str = my_func()(2, 2.5) # Works fine
b: int = my_func()(2, 2.5) # correctly fails, we know we don't get an int
c: str = my_func()("Hello", [12,13]) # Incorrectly passes. We don't know to reject those inputs.

We can be more specific, which takes a bit of care when writing the function, and allows MyPy to better help us later. In general, there are two main options for annotating functions and function-like things. There's Callable and there's Protocol.

Callable is generally more consise and works when you're dealing with positional arguments. Protocol is a bit more verbose, and works with keyword arguments too.

So, you can annotate your function as

def my_func() -> Callable[[int, float], str]:

That is, it returns a function which takes an int (for qux) and a float (for thud) and returns a string. Now, note that MyPy doesn't know what the input type is going to be, so it can't verify that bit. partial[str] would be just as compatible with Callable[[spam, ham, eggs], str]. It does, however, pass without errors, and it will then helpfully warn you if you try to pass in the wrong parameters to your Callable. That is,

my_func()(7, 2.6) # This will pass
my_func()("Hello", [12,13]) # This will now correctly fail.

Now, let's suppose instead that foo were defined as follows.

def foo(qux: int, bar: str, thud: float) -> str:
    # Does whatever
    return "Hi"

Once we've got a partial passing bar as a keyword argument, there is no way to get thud in as a positional argument. That means there's no way to use Callable to annotate this one. Instead, we have to use a Protocol.

The syntax is a bit weird. It works as follows.

class PartFoo(Protocol):
    def __call__(fakeSelf, qux: int, *, thud: float) -> str:
        ...

Teasing apart that __call__ line, we first have the fakeSelf entry. That's just notation: if call is a method, the first parameter gets swallowed.

Next, we have qux, annotated as an int as before. We then have the * marker to indicate that everything following is keyword only, because we can no longer reach thud in the real method positionally. Then we have thud with its annotation, and finally we have the -> str to give the return type.

Now if you define def my_func() -> PartFoo: you get the behaviour we want

my_func()(7, thud=1.5) # Works fine, qux is passed positionally, thud is a kwarg
my_func()(qux=7, thud=1.5) # Works fine, both qux and thud are kwargs
my_func()(7) # Correctly fails because thud is missing
my_func()(7, 1.5) # Correctly fails because thud can't be a positional arg.

The final situation that you could run into is where your original method has optional parameters. So, let's say

def foo(qux: int, bar: str, thud: float = 0.5) -> str:
    # Does whatever
    return "Hi"

Once again, we can't handle this precisely with Callable but Protocol is just fine. We simply ensure that the PartFoo protocol also specifies a default value for thud. Here I am using an ellipsis literal, as a gentle reminder that the actual value of that default may differ between implementations and the Protocol.

class PartFoo(Protocol):
    def __call__(fakeSelf, qux: int, *, thud: float=...) -> str:
        ...

Now our behavior is

my_func()(7, thud=1.5) # Works fine, qux is passed positionally, thud is a kwarg
my_func()(qux=7, thud=1.5) # Works fine, both qux and thud are kwargs
my_func()(7) # Works fine because thud is optional
my_func()(7, 1.5) # Correctly fails because thud can't be a positional arg.

To recap, the partial function returns a fairly vague functionish type, which you can use directly but would lose checking against the inputs. You can annotate it with something more specific, using Callable in simple cases and Protocol in more complicated ones.

Josiah
  • 1,072
  • 6
  • 15
  • 1
    Thanks for the insightful answer! Question: the ellipsis you put in the `Protocol` definition is a _literal_ ellipsis? Or is it something that the user is supposed to fill by themselves? I suggest this is clarified in the body of the answer. – Dev-iL Dec 25 '22 at 13:38