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 bound method `setter` 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 class `property` 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)