2

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.

  • 5
    No way, [fortunately](https://en.wikipedia.org/wiki/Liskov_substitution_principle). – STerliakov Dec 25 '22 at 17:52
  • You're not calling `super().__init__()` and similar, but you should be. Also, perhaps you can examine args with [isinstance](https://docs.python.org/3/library/functions.html#isinstance) at runtime, and signal an error if the contract is violated? – J_H Dec 30 '22 at 00:55

3 Answers3

1

One reasonable workaround would be to make the method take just the typed arguments, and leave the untyped arguments to a callable that the method returns. Since you can declare the return type of a callable without specifying the call signature by using an ellipsis, it solves your problem of leaving those additional arguments untyped:

from typing import Protocol, Callable

class MyProtocol(Protocol):
    def my_method(self, first_param: int) -> Callable[..., int]:
        ...

class Imp1(MyProtocol):
  def my_method(self, first_param: int) -> Callable[..., int]:
      def _my_method(x: float, y: float) -> int:
          return int(first_param - x + y)
      return _my_method

print(Imp1().my_method(5)(1.5, 2.5)) # outputs 6

Demo of the code passing mypy:

https://mypy-play.net/?mypy=latest&python=3.12&gist=677569f73f6fc3bc6e44858ef37e9faf

blhsing
  • 91,368
  • 6
  • 71
  • 106
1

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]
ljmc
  • 4,830
  • 2
  • 7
  • 26
  • Thanks for the explanation! I ended up doing something similar to this, but instead of using generic types for each extra param, I used a single generic type that could be a dataclass: https://gist.github.com/giuliano-oliveira/7d2194c9fcdd8f8071ccbd173461e037 – giuliano-oliveira Jan 04 '23 at 13:10
  • 1
    I see you have still used the protocol as a parent in your implementation, this is most likely wrong. When your implementation inherits from your protocol it becomes a protocol itself, which by essence is not an implementation. Protocols use structural subtyping rather than nominal subtyping, i.e. **they don't need inheritance**. – ljmc Jan 04 '23 at 14:11
  • I see, makes sense, but I am using basically what mypy suggests to do when you want to type check the subclass when instantiating it. 'Explicitly including a protocol as a base class is also a way of documenting that your class implements a particular protocol' Is there some kind of missmatch between the PEP 544 and mypy? https://mypy.readthedocs.io/en/stable/protocols.html#defining-subprotocols-and-subclassing-protocols – giuliano-oliveira Jan 04 '23 at 20:39
  • 1
    Ok, I had a further look and I must say I was incorrect, you can indeed inherit a protocol explicitly, which allows default implementations and such. I would rather have ABCs nominal inheritance and default implementation, and Protocols for interfaces, but that’s a personal choice. – ljmc Jan 04 '23 at 21:16
0
  1. Signature of method 'Imp1.my_method()' does not match signature of the base method in class 'MyProtocol'

    must be I suppose

     class Imp1(MyProtocol):
         def my_method(self, first_param: int, *args: Any, **kwargs: Any) -> int:
             ...
    
  2. Yours Imp2 the same as in Imp1 but does not even have first named parameter.

Ronin
  • 1,811
  • 1
  • 16
  • 17