pythonprotocolspython-typingmypy

Kwargs in a Protocol implementer: what is a valid signature?


My question is simple. I have this protocol:

from typing import Protocol

class LauncherTemplateConfig(Protocol):
    def launch_program_cmd(self, **kwargs) -> list[str]:
        pass

And this implementation of the protocol, which I would expect mypy passes, but it does not:

from typing import Optional
from pathlib import Path

class MyLauncherTemplateConfig:
    def launch_program_cmd(
        self, some_arg: Optional[Path] = None, another_arg=1
    ) -> list[str]:

I would expect the parameters in MyLauncherTemplateConfig.launch_program_cmd to be compatible with **kwargsin the Protocol class.

Not sure if I am doing something wrong...


Solution

  • The general principle

    If you want MyPy to accept that a certain class implements the interface defined in a Protocol, a relevant method in the concrete implementation must be no less permissive in the arguments it will accept than the abstract version of that method as defined in the Protocol. This is consistent with other principles of object-oriented programming such as the Liskov Substitution Principle.

    The specific issue here

    Your Protocol defines an interface in which the launch_program_cmd method can be called with any keyword-arguments, and not fail at runtime. Your concrete implementation does not satisfy this interface, as any keyword arguments other than some_arg or another_arg will cause the method to raise an error.

    Possible solution

    If you want MyPy to declare your class as a safe implementation of your Protocol, you have two options. You can either adjust the signature of the method in the Protocol to be more specific, or adjust the signature of the method in the concrete implementation to be more generic. In the case of the latter, you might do it like this:

    from typing import Any, Protocol, Optional
    from pathlib import Path
    
    class LauncherTemplateConfig(Protocol):
        def launch_program_cmd(self, **kwargs: Any) -> list[str]: ...
    
    
    class MyLauncherTemplateConfig:
        def launch_program_cmd(self, **kwargs: Any) -> list[str]:
            some_arg: Optional[Path] = kwargs.get('some_arg')
            another_arg: int = kwargs.get('another_arg', 1)
            # and then the rest of your method
    

    By using the dict.get method, we can retain the default values in your implementation, but do it in a way that sticks to the generic signature of the method that was declared in the Protocol