Search code examples
pythonpython-typingmypy

How to annotate attribute that can be implemented as property?


I am trying to make mypy happy with my type annotations. Here is minimal example:

class FooInterface:
    x: int


class FooWithAttribute(FooInterface):
    x: int = 0


class FooWithProperty(FooInterface):
    @property
    def x(self) -> int:
        return 0

To my human understanding everything is fine: both FooWithAttribute().x and FooWithProperty().x will return 0 which is int, no type errors. However mypy complains:

error: Signature of "x" incompatible with supertype "FooInterface"

Is there a way to tell mypy that everything is OK? Right now the only way I found is annotating x: typing.Any in FooInterface which wastes the information that x is int.


Solution

  • Mypy is actually pointing out a legitimate bug in your program. To demonstrate, suppose you have a program that looks like this:

    def mutate(f: FooInterface) -> None:
        f.x = 100
    

    Seems fine, right? But what happens if we do mutate(FooWithProperty())? Python will actually crash with an AttributeError!

    Traceback (most recent call last):
      File "test.py", line 19, in <module>
        mutate(FooWithProperty())
      File "test.py", line 16, in mutate
        f.x = 100
    AttributeError: can't set attribute
    

    To make mypy happy, you basically have two options:

    1. Make FooInterface.x also be a read-only property
    2. Implement a setter for FooWithProperty.x to make it writable

    I'm guessing that in your case, you probably want to take approach 1. If you do so, mypy will correctly point out that the line f.x = 100 is not permitted:

    from abc import abstractmethod
    
    class FooInterface:
        # Marking this property as abstract is *optional*. If you do it,
        # mypy will complain if you forget to define x in a subclass.
        @property
        @abstractmethod
        def x(self) -> int: ...
    
    class FooWithAttribute(FooInterface):
        # No complaints from mypy here: having this attribute be writable
        # won't violate the Liskov substitution principle -- it's safe to
        # use FooWithAttribute in any location that expects a FooInterface.
        x: int = 0
    
    class FooWithProperty(FooInterface):
        @property
        def x(self) -> int:
            return 0
    
    def mutate(f: FooInterface) -> None:
        # error: Property "x" defined in "FooInterface" is read-only
        f.x = 100
    
    mutate(FooWithProperty())
    

    Approach 2 unfortunately doesn't quite work yet due to a bug in mypy -- mypy doesn't correctly understand how to handle overriding an attribute with a property. The workaround in this case is to make FooInterface.x a property with a setter.