Search code examples
pythontype-hintingmypypython-3.9duck-typing

How to type only the first positional parameter of a Protocol method and let the others be untyped?


Problem

How to only type the first positional parameter of a Protocol method and let the others be untyped?

Example, having a protocol named MyProtocol that has a method named my_method that requires only the first positional parameter to be an int, while letting the rest be untyped. the following class would implement it correctly without error:

class Imp1(MyProtocol):
  def my_method(self, first_param: int, x: float, y: float) -> int:
    return int(first_param - x + y)

However the following implementation wouldn't implement it correctly, since the first parameter is a float:

class Imp2(MyProtocol):
  def my_method(self, x: float, y: float) -> int: # Error, method must have a int parameter as a first argument after self
    return int(x+y)

I thought I would be able to do that with *args, and **kwargs combined with Protocol like so:

from typing import Protocol, Any

class MyProtocol(Protocol):
    def my_method(self, first_param: int, /, *args: Any, **kwargs: Any) -> int:
        ...

But (in mypy) this makes both Imp1 and Imp2 fail, because it forces the method contract to really have a *args, **kwargs like so:

class Imp3(MyProtocol):
    def my_method(self, first_param: int, /, *args: Any, **kwargs: Any) -> int:
        return first_param

But this does not solves what I am trying to achieve, that is make the implementation class have any typed/untyped parameters except for the first parameter.

Workaround

I manged to circumvent the issue by using an abstract class with a setter set_first_param, like so:

from abc import ABC, abstractmethod
from typing import Any


class MyAbstractClass(ABC):
    _first_param: int

    def set_first_param(self, first_param: int):
        self._first_param = first_param

    @abstractmethod
    def my_method(self, *args: Any, **kwargs: Any) -> int:
        ...


class AbcImp1(MyAbstractClass):
    def my_method(self, x: float, y: float) -> int:
        return int(self._first_param + x - y) # now i can access the first_parameter with self._first_param

But this totally changes the initial API that I am trying to achieve, and in my opinion makes less clear to the implementation method that this parameter will be set before calling my_method.

Note

This example was tested using python version 3.9.13 and mypy version 0.991.


Solution

  • If your MyProtocol can accept any number of arguments, you cannot have a subtype (or implementation) which accepts a set number, this breaks the Liskov substitution principle as the subtype only accepts a limited set of cases accepted by the supertype.

    [original paragraph]

    Then, if you keep on inheriting from Protocol, you keep on making protocols, protocols are different from ABCs, they use structural subtyping (not nominal subtyping), meaning that as long as an object implements all the methods/properties of a protocol it is an instance of that protocol (see PEP 544 for more details).

    [end original paragraph]

    [edit upon further reading]

    In my opinion, protocols should only be inherited by other protocols which will be used with structural subtyping. For nominal subtyping (which for instance allows default implementation) I would use ABCs.

    [edit upon further reading]

    Without more detail on the implementations you'd want to use, @blhsing's solution is probably the most open because it does not type the Callable's call signature.

    Here is a set of implementations around a generic protocol with contravariant types (bound to float as it is the top of the numeric tower), which would allow any numeric type for the two x and y arguments.

    from typing import Any, Generic, Protocol, TypeVar
    
    T = TypeVar("T", contravariant=True, bound=float)
    U = TypeVar("U", contravariant=True, bound=float)
    
    class MyProtocol(Protocol[T, U]):
        def my_method(self, first_param: int, x: T, y: U) -> int:
            ...
    
    class ImplementMyProtocol1(Generic[T, U]):
        """Generic implementation, needs typing"""
        def my_method(self, first_param: int, x: T, y: U) -> int:
            return int(first_param - x + y)
    
    class ImplementMyProtocol2:
        """Float implementation, and ignores first argument"""
        def my_method(self, _: int, x: float, y: float) -> int:
            return int(x + y)
    
    class ImplementMyProtocol3:
        """Another float implementation, with and extension"""
        def my_method(self, first_param: int, x: float, y: float, *args: float) -> int:
            return int(first_param - x + y + sum(args))
    
    def use_MyProtocol(inst: MyProtocol[T, U], n: int, x: T, y: U) -> int:
        return inst.my_method(n, x, y)
    
    use_MyProtocol(ImplementMyProtocol1[float, float](), 1, 2.0, 3.0)  # OK MyProtocol[float, float]
    use_MyProtocol(ImplementMyProtocol1[int, int](), 1, 2, 3)  # OK MyProtocol[int, int]
    use_MyProtocol(ImplementMyProtocol2(), 1, 2.0, 3.0)  # OK MyProtocol[float, float]
    use_MyProtocol(ImplementMyProtocol3(), 1, 2.0, 3.0)  # OK MyProtocol[float, float]