11

foo.py:

kwargs = {"a": 1, "b": "c"}

def consume(*, a: int, b: str) -> None:
    pass

consume(**kwargs)

mypy foo.py:

error: Argument 1 to "consume" has incompatible type "**Dict[str, object]"; expected "int"
error: Argument 1 to "consume" has incompatible type "**Dict[str, object]"; expected "str"

This is because object is a supertype of int and str, and is therefore inferred. If I declare:

from typing import TypedDict

class KWArgs(TypedDict):
    a: int
    b: str

and then annotate kwargs as KWArgs, the mypy check passes. This achieves type safety, but requires me to duplicate the keyword argument names and types for consume in KWArgs. Is there a way to generate this TypedDict from the function signature at type checking time, such that I can minimize the duplication in maintenance?

Mario Ishac
  • 5,060
  • 3
  • 21
  • 52

2 Answers2

4

To the best of my knowledge, there is no direct workaround on this [1], but there is another elegant way to achieve exactly that:

We can utilize the typings NamedTuple to create an object that holds the parameter:

ConsumeContext = NamedTuple('ConsumeContext', [('a', int), ('b', str)])

Now we define the consume method to accept it as a parameter:

def consume(*, consume_context : ConsumeContext) -> None:
    print(f'a : {consume_context.a} , b : {consume_context.b}')

The whole code would be:

from typing import NamedTuple

ConsumeContext = NamedTuple('ConsumeContext', [('a', int), ('b', str)])

def consume(*, consume_context : ConsumeContext) -> None:
    print(f'a : {consume_context.a} , b : {consume_context.b}')

ctx = ConsumeContext(a=1, b='sabich')

consume(consume_context=ctx)

And running mypy would yield:

Success: no issues found in 1 source file

It will recognize that a and b are parameters, and approve that.

And running the code would output:

a : 1 , b : sabich

However, if we change b to be not a string, mypy will complain:

foo.py:9: error: Argument "b" to "ConsumeContext" has incompatible type "int"; expected "str"
Found 1 error in 1 file (checked 1 source file)

By this, we achieve type checking for a method by defining once it's parameters and types.

[1] Because if either defining TypedDict or function signature, based on the other, would require to know the other's __annotations__, which is not known on check-time, and defining a decorator to cast types on run-time misses the point of type checking.

Aviv Yaniv
  • 6,188
  • 3
  • 7
  • 22
  • 1
    Great solution. In some cases the function is defined in a third-party library, and I wouldn't be able to make the function's parameter `ConsumeContext` given that I can't change the source. But this works great for my own defined functions. I'll leave bounty up for few more days for the 1% chance this can be done from a function signature at check time without having to fetch `__annotations__`, but otherwise I'll accept and give bounty. – Mario Ishac Sep 18 '20 at 16:34
  • 5
    I don't love this solution because it makes the method signature less readable and the usage more opaque. It is committing one sin (making code less readable) to prevent another--arguably less serious--sin (duplicating code); I don't know if that is a worthy tradeoff. – Thismatters Apr 30 '21 at 13:02
2

This will be available in Python 3.12 via PEP 692:

from typing import TypedDict, Unpack, Required, NotRequired

class KWArgs(TypedDict):
    a: Required[int]
    b: NotRequired[str]

def consume(**kwargs: Unpack[KWArgs]) -> None:
    a = kwargs["a"]
    b = kwargs.get("b", ...)

consume()              # Not allowed.
consume(a=1)           # Allowed.
consume(a=1, b="abc")  # Allowed.
Mario Ishac
  • 5,060
  • 3
  • 21
  • 52