How to make mypy like my protocols that work at runtime with runtime_checkable

Question:

I defined a couple of protocols like so:

import json
from typing import Any, Protocol, TypeVar, runtime_checkable

T_co = TypeVar('T_co', covariant=True)
T = TypeVar('T')


@runtime_checkable
class SupportsRead(Protocol[T_co]):
    def read(self, __length: int = ...) -> T_co: ...


@runtime_checkable
class SupportsWrite(Protocol[T_co]):
    def write(self, data: str | bytes): ...


@runtime_checkable
class SerializerToString(Protocol):
    def dumps(self, value: Any, *argv, **kwargs) -> str: ...

    def loads(self, value: str | bytes, *argv, **kwargs) -> Any: ...


@runtime_checkable
class SerializerToFile(Protocol):
    def dump(self, value: Any, file: SupportsWrite[str | bytes], **kwargs) -> None: ...

    def load(self, file: SupportsRead[str | bytes], **kwargs) -> Any: ...


@runtime_checkable
class Serializer(SerializerToString, SerializerToFile, Protocol):
    pass


class MySerializer:
    def dumps(self, value: Any) -> str:
        return f"dumps {value}"

    def loads(self, value: str) -> Any:
        return f"loads {value}"


var1: SerializerToFile = json
var2: SerializerToString = json
var3: Serializer = json
var4: SerializerToString = MySerializer()

assert isinstance(var1, SerializerToFile)
assert isinstance(var2, SerializerToString)
assert isinstance(var3, Serializer)
assert isinstance(var4, SerializerToString)
print("everything ok")

It runs without error, but mypy marks all the assigments to var1var4 with errors like so:

test11.py:42: error: Incompatible types in assignment (expression has type Module, variable has type "SerializerToFile")  [assignment]
test11.py:42: note: Following member(s) of "Module json" have conflicts:
test11.py:42: note:     Expected:
test11.py:42: note:         def dump(value: Any, file: SupportsWrite[Union[str, bytes]], **kwargs: Any) -> None
test11.py:42: note:     Got:
test11.py:42: note:         def dump(obj: Any, fp: SupportsWrite[str], *, skipkeys: bool = ..., ensure_ascii: bool = ..., check_circular: bool = ..., allow_nan: bool = ..., cls: Optional[Type[JSONEncoder]] = ..., indent: Union[None, int, str] = ..., separators: Optional[Tuple[str, str]] = ..., default: Optional[Callable[[Any], Any]] = ..., sort_keys: bool = ..., **kwds: Any) -> None
test11.py:42: note:     Expected:
test11.py:42: note:         def load(file: SupportsRead[Union[str, bytes]], **kwargs: Any) -> Any
test11.py:42: note:     Got:
test11.py:42: note:         def load(fp: SupportsRead[Union[str, bytes]], *, cls: Optional[Type[JSONDecoder]] = ..., object_hook: Optional[Callable[[Dict[Any, Any]], Any]] = ..., parse_float: Optional[Callable[[str], Any]] = ..., parse_int: Optional[Callable[[str], Any]] = ..., parse_constant: Optional[Callable[[str], Any]] = ..., object_pairs_hook: Optional[Callable[[List[Tuple[Any, Any]]], Any]] = ..., **kwds: Any) -> Any
test11.py:43: error: Incompatible types in assignment (expression has type Module, variable has type "SerializerToString")  [assignment]
test11.py:43: note: Following member(s) of "Module json" have conflicts:
test11.py:43: note:     Expected:
test11.py:43: note:         def dumps(value: Any, *argv: Any, **kwargs: Any) -> str
test11.py:43: note:     Got:
test11.py:43: note:         def dumps(obj: Any, *, skipkeys: bool = ..., ensure_ascii: bool = ..., check_circular: bool = ..., allow_nan: bool = ..., cls: Optional[Type[JSONEncoder]] = ..., indent: Union[None, int, str] = ..., separators: Optional[Tuple[str, str]] = ..., default: Optional[Callable[[Any], Any]] = ..., sort_keys: bool = ..., **kwds: Any) -> str
test11.py:43: note:     Expected:
test11.py:43: note:         def loads(value: Union[str, bytes], *argv: Any, **kwargs: Any) -> Any
test11.py:43: note:     Got:
test11.py:43: note:         def loads(s: Union[str, bytes, bytearray], *, cls: Optional[Type[JSONDecoder]] = ..., object_hook: Optional[Callable[[Dict[Any, Any]], Any]] = ..., parse_float: Optional[Callable[[str], Any]] = ..., parse_int: Optional[Callable[[str], Any]] = ..., parse_constant: Optional[Callable[[str], Any]] = ..., object_pairs_hook: Optional[Callable[[List[Tuple[Any, Any]]], Any]] = ..., **kwds: Any) -> Any
test11.py:44: error: Incompatible types in assignment (expression has type Module, variable has type "Serializer")  [assignment]
test11.py:44: note: Following member(s) of "Module json" have conflicts:
test11.py:44: note:     Expected:
test11.py:44: note:         def dump(value: Any, file: SupportsWrite[Union[str, bytes]], **kwargs: Any) -> None
test11.py:44: note:     Got:
test11.py:44: note:         def dump(obj: Any, fp: SupportsWrite[str], *, skipkeys: bool = ..., ensure_ascii: bool = ..., check_circular: bool = ..., allow_nan: bool = ..., cls: Optional[Type[JSONEncoder]] = ..., indent: Union[None, int, str] = ..., separators: Optional[Tuple[str, str]] = ..., default: Optional[Callable[[Any], Any]] = ..., sort_keys: bool = ..., **kwds: Any) -> None
test11.py:44: note:     Expected:
test11.py:44: note:         def dumps(value: Any, *argv: Any, **kwargs: Any) -> str
test11.py:44: note:     Got:
test11.py:44: note:         def dumps(obj: Any, *, skipkeys: bool = ..., ensure_ascii: bool = ..., check_circular: bool = ..., allow_nan: bool = ..., cls: Optional[Type[JSONEncoder]] = ..., indent: Union[None, int, str] = ..., separators: Optional[Tuple[str, str]] = ..., default: Optional[Callable[[Any], Any]] = ..., sort_keys: bool = ..., **kwds: Any) -> str
test11.py:44: note:     <2 more conflict(s) not shown>
test11.py:45: error: Incompatible types in assignment (expression has type "MySerializer", variable has type "SerializerToString")  [assignment]
test11.py:45: note: Following member(s) of "MySerializer" have conflicts:
test11.py:45: note:     Expected:
test11.py:45: note:         def dumps(self, value: Any, *argv: Any, **kwargs: Any) -> str
test11.py:45: note:     Got:
test11.py:45: note:         def dumps(self, value: Any) -> str
test11.py:45: note:     Expected:
test11.py:45: note:         def loads(self, value: Union[str, bytes], *argv: Any, **kwargs: Any) -> Any
test11.py:45: note:     Got:
test11.py:45: note:         def loads(self, value: str) -> Any
Found 4 errors in 1 file (checked 1 source file)

Is there a way to please mypy with those protocols?

Asked By: Copperfield

||

Answers:

I’ll go through the problems one by one.

Given the following protocols for file-like objects:

from typing import Any, Protocol, TypeVar, runtime_checkable

T_co = TypeVar('T_co', covariant=True)


@runtime_checkable
class SupportsRead(Protocol[T_co]):
    def read(self, __length: int = ...) -> T_co: ...


@runtime_checkable
class SupportsWrite(Protocol[T_co]):
    def write(self, data: str | bytes) -> Any: ...

SerializerToFile

Simply adjusting your protocol members’ parameter names to reflect those of the corresponding functions in the json module fixes the issue:

@runtime_checkable
class SerializerToFile(Protocol):
    def dump(
        self,
        obj: Any,                        # was called `value`
        fp: SupportsWrite[str | bytes],  # was called `file`
        **kwargs: Any,                   # was missing `Any` (optional)
    ) -> None: ...

    def load(
        self,
        fp: SupportsRead[str | bytes],  # was called `file`
        **kwargs: Any,                  # was missing `Any` (optional)
    ) -> Any: ...


import json

var1: SerializerToFile = json  # passes

SerializerToString

Again, the names did not match, but also your protocol methods accept arbitrary positional arguments, which neither the loads nor the dumps function does:

@runtime_checkable
class SerializerToString(Protocol):
    def dumps(
        self,
        obj: Any,       # was called `value`
        # *argv: Any,   # this needs to be removed
        **kwargs: Any,  # was missing `Any` (optional)
    ) -> str: ...

    def loads(
        self,
        s: str | bytes,  # was called `value`
        # *argv: Any,    # this needs to be removed
        **kwargs: Any,   # was missing `Any` (optional)
    ) -> Any: ...


import json

var1: SerializerToString = json  # passes

Serializer (intersection)

With the fixed protocols from above, the intersection of both of them is also a valid annotation to use for the json module:

...

@runtime_checkable
class Serializer(SerializerToString, SerializerToFile, Protocol):
    pass


import json

var1: Serializer = json  # passes

Custom serializer

Now the custom serializer needs to be adjusted accordingly, so that it matches the protocol as well:

...

class MySerializer:
    def dumps(self, obj: Any, **_kwargs: Any) -> str:
        return f"dumps {obj}"

    def loads(self, s: str | bytes, **_kwargs: Any) -> Any:
        return f"loads {s!r}"


var1: SerializerToString = MySerializer()  # passes

The first loads argument must accept bytes as well, since the protocol defines it as str | bytes. Subtypes are contravariant in their method parameters. So you could only widen the parameter types accepted by the serializer, never narrow it. Something silly like str | bytes | tuple[Any] for the first argument on your loads method would still work, but just str does not.


Attempt at explanation

As opposed to the Callable type, where you may only define the types that parameters in certain positions of the callable take, the protocol methods are more specific, since Python does allow all "normal" arguments to a function call to be passed as keyword-arguments (i.e. by name).

The exceptions are variable-number positional arguments, defined via *args and arguments specifically defined as positional-only by having their parameters precede a / in the signature.

So if you say that something is a structural subtype of e.g. SerializerToFile, if it implements a method dumps, where the first parameter is "positional-or-keyword" and named value, you could then use an object of that type somewhere and call that method as dumps(value=123). But the json.dumps method can not be called that way because its first parameter is named obj, so a call of json.dumps(obj={}) works, whereas the call json.dumps(value={}) would fail. Therefore json does not conform to the protocol.

Similarly, if your protocol allows "var. args" in a method, i.e. dumps(obj: Any, *args: Any, **kwargs: Any), that means you might want to call that method on an instance of that type as dumps({}, 1, 2, 3, 4), but that is not allowed for json.dumps because aside from the very first parameter it explicitly defines the rest as keyword-only (all following the *).

Answered By: Daniil Fajnberg