简体   繁体   中英

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 imagined that 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 .

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.

Then, if you keep on inheriting from Protocol , you keep on making protocols, protocols are different from ABC s, 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).

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]

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

  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.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM