Search code examples
pythonmypysetter

Dynamically generate mypy-compliant property setters


I am trying to declare a base class with certain attributes for which the (very expensive) calculation differs depending on the subclass, but that accepts injecting the value if previously calculated

class Test:
    _value1: int | None = None
    _value2: str | None = None
    _value3: list | None = None
    _value4: dict | None = None

    @property
    def value1(self) -> int:
        if self._value1 is None:
            self._value1 = self._get_value1()
        return self._value1

    @value1.setter
    def value1(self, value1: int) -> None:
        self._value1 = value1

    def _get_value1(self) -> int:
        raise NotImplementedError

class SubClass(Test):
    def _get_value1(self) -> int:
        time.sleep(1000000)
        return 1


instance = SubClass()
instance.value1 = 1
print(instance.value1) # doesn't wait

As you can see it becomes very verbose, with every property having three different functions associated to it.

Is there a way to dynamically declare at the very least the setter, so that mypy knows it's always the same function but with proper typing? Or in general, is there a more concise way to declare this kind of writable property for which the underlying implementation must be implemented by the base class, in bulk?

Declaring __setattr__ doesn't seem to be viable, because just having __setattr__ declared tricks mpy into thinking I can just assign any value to anything else that's not overloaded, while I still want errors to show up in case I'm trying to assign the wrong attributes. It also doesn't fix that I still need to declare setters, otherwise it thinks the value is immutable.


Solution

  • If you wanted to simply omit writing @property.setter (this part)

        @value1.setter
        def value1(self, value1: int) -> None:
            self._value1 = value1
    

    one possible implementation would be to subclass property to automatically implement a __set__ method which matches the behaviour specified in your example:

    from __future__ import annotations
    
    import typing as t
    
    
    if t.TYPE_CHECKING:
        import collections.abc as cx
    
    
    _ValueT = t.TypeVar("_ValueT")
    
    
    class settable(property, t.Generic[_ValueT]):
    
        fget: cx.Callable[[t.Any], _ValueT]
    
        def __init__(self, fget: cx.Callable[[t.Any], _ValueT], /) -> None:
            super().__init__(fget)
    
        if t.TYPE_CHECKING:
            # Type-safe descriptor protocol for property retrieval methods (`__get__`)
            # see https://docs.python.org/3/howto/descriptor.html
            # These are under `typing.TYPE_CHECKING` because we don't need
            # to modify their implementation from `builtins.property`, but
            # just need to add type-safety.
            @t.overload  # type: ignore[override, no-overload-impl]
            def __get__(self, instance: None, Class: type, /) -> settable[_ValueT]:
    
                """
                Retrieving a property from on a class (`instance: None`) retrieves the
                property object (`settable[_ValueT]`)
                """
    
            @t.overload
            def __get__(self, instance: object, Class: type, /) -> _ValueT:
    
                """
                Retrieving a property from the instance (all other `typing.overload` cases)
                retrieves the value
                """
    
        def __set__(self, instance: t.Any, value: _ValueT) -> None:
    
            """
            Type-safe setter method. Grabs the name of the function first decorated with
            `@settable`, then calls `setattr` on the given value with an attribute name of
            '_<function name>'.
            """
    
            setattr(instance, f"_{self.fget.__name__}", value)
    
    

    Here's a demonstration of type-safety:

    import time
    
    class Test:
        _value1: int | None = None
        _value2: str | None = None
        _value3: list | None = None
        _value4: dict | None = None
    
        @settable
        def value1(self) -> int:
            if self._value1 is None:
                self._value1 = self._get_value1()
            return self._value1
    
        def _get_value1(self) -> int:
            raise NotImplementedError
    
    class SubClass(Test):
        def _get_value1(self) -> int:
            time.sleep(1000000)
            return 1
    
    >>> instance: SubClass = SubClass()
    >>> instance.value1 = 1  # OK
    >>>
    >>> if t.TYPE_CHECKING:
    ...     reveal_type(instance.value1)  # mypy: Revealed type is "builtins.int"
    ...
    >>> print(instance.value1)
    1
    >>> instance.value1 = "1"  # mypy: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment]
    >>> SubClass.value1 = 1  # mypy: Cannot assign to a method [assignment]
    ...                      # mypy: Incompatible types in assignment (expression has type "int", variable has type "settable[int]") [assignment]