ruff/crates/ty_python_semantic/resources/mdtest/properties.md
2025-05-03 19:49:15 +02:00

7.9 KiB

Properties

property is a built-in class in Python that can be used to model class attributes with custom getters, setters, and deleters.

Basic getter

property is typically used as a decorator on a getter method. It turns the method into a property object. When accessing the property on an instance, the descriptor protocol is invoked, which calls the getter method:

class C:
    @property
    def my_property(self) -> int:
        return 1

reveal_type(C().my_property)  # revealed: int

When a property is accessed on the class directly, the descriptor protocol is also invoked, but property.__get__ simply returns itself in this case (when instance is None):

reveal_type(C.my_property)  # revealed: property

Getter and setter

A property can also have a setter method, which is used to set the value of the property. The setter method is defined using the @<property_name>.setter decorator. The setter method takes the value to be set as an argument.

class C:
    @property
    def my_property(self) -> int:
        return 1

    @my_property.setter
    def my_property(self, value: int) -> None:
        pass

c = C()
reveal_type(c.my_property)  # revealed: int
c.my_property = 2

# error: [invalid-assignment]
c.my_property = "a"

property.getter

property.getter can be used to overwrite the getter method of a property. This does not overwrite the existing setter:

class C:
    @property
    def my_property(self) -> int:
        return 1

    @my_property.setter
    def my_property(self, value: int) -> None:
        pass

    @my_property.getter
    def my_property(self) -> str:
        return "a"

c = C()
reveal_type(c.my_property)  # revealed: str
c.my_property = 2

# error: [invalid-assignment]
c.my_property = "b"

property.deleter

We do not support property.deleter yet, but we make sure that it does not invalidate the getter or setter:

class C:
    @property
    def my_property(self) -> int:
        return 1

    @my_property.setter
    def my_property(self, value: int) -> None:
        pass

    @my_property.deleter
    def my_property(self) -> None:
        pass

c = C()
reveal_type(c.my_property)  # revealed: int
c.my_property = 2
# error: [invalid-assignment]
c.my_property = "a"

Failure cases

Attempting to write to a read-only property

When attempting to write to a read-only property, we emit an error:

class C:
    @property
    def attr(self) -> int:
        return 1

c = C()

# error: [invalid-assignment]
c.attr = 2

Attempting to read a write-only property

When attempting to read a write-only property, we emit an error:

class C:
    def attr_setter(self, value: int) -> None:
        pass
    attr = property(fset=attr_setter)

c = C()
c.attr = 1

# TODO: An error should be emitted here, and the type should be `Unknown`
# or `Never`. See https://github.com/astral-sh/ruff/issues/16298 for more
# details.
reveal_type(c.attr)  # revealed: Unknown | property

Wrong setter signature

class C:
    @property
    def attr(self) -> int:
        return 1
    # error: [invalid-argument-type] "Argument to this function is incorrect: Expected `(Any, Any, /) -> None`, found `def attr(self) -> None`"
    @attr.setter
    def attr(self) -> None:
        pass

Wrong getter signature

class C:
    # error: [invalid-argument-type] "Argument to this function is incorrect: Expected `((Any, /) -> Any) | None`, found `def attr(self, x: int) -> int`"
    @property
    def attr(self, x: int) -> int:
        return 1

Limitations

Manually constructed property

Properties can also be constructed manually using the property class. We partially support this:

class C:
    def attr_getter(self) -> int:
        return 1
    attr = property(attr_getter)

c = C()
reveal_type(c.attr)  # revealed: Unknown | int

But note that we return Unknown | int because we did not declare the attr attribute. This is consistent with how we usually treat attributes, but here, if we try to declare attr as property, we fail to understand the property, since the property declaration shadows the more precise type that we infer for property(attr_getter) (which includes the actual information about the getter).

class C:
    def attr_getter(self) -> int:
        return 1
    attr: property = property(attr_getter)

c = C()
reveal_type(c.attr)  # revealed: Unknown

Behind the scenes

In this section, we trace through some of the steps that make properties work. We start with a simple class C and a property attr:

class C:
    def __init__(self):
        self._attr: int = 0

    @property
    def attr(self) -> int:
        return self._attr

    @attr.setter
    def attr(self, value: str) -> None:
        self._attr = len(value)

Next, we create an instance of C. As we have seen above, accessing attr on the instance will return an int:

c = C()

reveal_type(c.attr)  # revealed: int

Behind the scenes, when we write c.attr, the first thing that happens is that we statically look up the symbol attr on the meta-type of c, i.e. the class C. We can emulate this static lookup using inspect.getattr_static, to see that attr is actually an instance of the property class:

from inspect import getattr_static

attr_property = getattr_static(C, "attr")
reveal_type(attr_property)  # revealed: property

The property class has a __get__ method, which makes it a descriptor. It also has a __set__ method, which means that it is a data descriptor (if there is no setter, __set__ is still available but yields an AttributeError at runtime).

reveal_type(type(attr_property).__get__)  # revealed: <wrapper-descriptor `__get__` of `property` objects>
reveal_type(type(attr_property).__set__)  # revealed: <wrapper-descriptor `__set__` of `property` objects>

When we access c.attr, the __get__ method of the property class is called, passing the property object itself as the first argument, and the class instance c as the second argument. The third argument is the "owner" which can be set to None or to C in this case:

reveal_type(type(attr_property).__get__(attr_property, c, C))  # revealed: int
reveal_type(type(attr_property).__get__(attr_property, c, None))  # revealed: int

Alternatively, the above can also be written as a method call:

reveal_type(attr_property.__get__(c, C))  # revealed: int

When we access attr on the class itself, the descriptor protocol is also invoked, but the instance argument is set to None. When instance is None, the call to property.__get__ returns the property instance itself. So the following expressions are all equivalent

reveal_type(attr_property)  # revealed: property
reveal_type(C.attr)  # revealed: property
reveal_type(attr_property.__get__(None, C))  # revealed: property
reveal_type(type(attr_property).__get__(attr_property, None, C))  # revealed: property

When we set the property using c.attr = "a", the __set__ method of the property class is called. This attribute access desugars to

type(attr_property).__set__(attr_property, c, "a")

# error: [call-non-callable] "Call of wrapper descriptor `property.__set__` failed: calling the setter failed"
type(attr_property).__set__(attr_property, c, 1)

which is also equivalent to the following expressions:

attr_property.__set__(c, "a")
# error: [call-non-callable]
attr_property.__set__(c, 1)

C.attr.__set__(c, "a")
# error: [call-non-callable]
C.attr.__set__(c, 1)

Properties also have fget and fset attributes that can be used to retrieve the original getter and setter functions, respectively.

reveal_type(attr_property.fget)  # revealed: def attr(self) -> int
reveal_type(attr_property.fget(c))  # revealed: int

reveal_type(attr_property.fset)  # revealed: def attr(self, value: str) -> None
reveal_type(attr_property.fset(c, "a"))  # revealed: None

# error: [invalid-argument-type]
attr_property.fset(c, 1)