Search code examples
pythonmappingmypytypingpyright

Why doesn't a read-only Mapping work as a type hint for a Dict attribute in Python?


Why does a read-only Mapping not work as a type hint for a Dict attribute? I know dict is mutable, which makes the field invariant, but could you explain what can go wrong with passing it to a read-only Mapping type?

Consider the following code:

from typing import Dict, Mapping, Protocol

class A:
    field: Dict

class B(Protocol):
    field: Mapping

def f(arg: B):
    print(arg)

f(A())

This code will raise a type error at the call to f(A()) in pyright:

Argument of type "A" cannot be assigned to parameter "arg" of type "B" in function "f"
  "A" is incompatible with protocol "B"
    "field" is invariant because it is mutable
    "field" is an incompatible type
      "Dict[Unknown, Unknown]" is incompatible with "Mapping[Unknown, Unknown]"

and in mypy:

error: Argument 1 to "f" has incompatible type "A"; expected "B"  [arg-type]
note: Following member(s) of "A" have conflicts:
note:     field: expected "Mapping[Any, Any]", got "Dict[Any, Any]"

Why is this? The B protocol defines an attribute field of type Mapping, which should include both mutable and immutable mappings. However, A class defines an attribute field of type Dict, a mutable mapping object.

Shouldn't a read-only Mapping work in place of a Dict object, since it only provides read-only access to the mapping object?


Solution

  • The code in the question is really not type safe, type checkers are correct in their suggestions.

    If you have an argument annotated as Mapping, you can assign to it anything you want - it does not affect the caller's passed value. When you pass any mutable or nested structure containing a Mapping, you should still be allowed to assign anything to that item/field. Let's look at the following:

    from collections import Counter
    from typing import Dict, Mapping, Protocol
    
    
    class B(Protocol):
        field: Mapping[str, int]
    
    
    class A:
        field: Dict[str, int]
    
    
    def f(arg: B) -> None:
        arg.field = Counter()
        print(arg)
    
    
    a = A()
    f(a)
    assert isinstance(a.field, dict)  # Oops
    

    Is f correct from typing perspective? Yes, it got something with Mapping field and replaced it with another Mapping, nothing to worry about. Hm? But the caller of f passed A instance in, and might still (correctly) expect that field value remains a dict. If the type checker remains silent, this error is not noticed.

    To avoid this warning, you can subclass the protocol directly, marking the A class as implementer. This is a subtle place in type system, which is difficult to explain (probably it's about exchanging strictness for simplicity), because your implementation will still fail with the same error, but without type checker worries. The same happens with usual classes: subclasses can override fields with more specific field subtypes. Such usage is denied only for protocols (for usual classes LSP is not violated, because attributes mutation is explicitly prohibited) - probably because they serve a different purpose. As per PEP544:

    By default, protocol variables as defined above are considered readable and writable. To define a read-only protocol variable, one can use an (abstract) property.

    Here's a playground with a bit more code to play with.