The descriptor protocol allow us to completely customize attribute access. Python’s documentation describes the protocol with types involved describ

Python Type Hints - How to Type a Descriptor

submited by
Style Pass
2022-01-13 18:00:06

The descriptor protocol allow us to completely customize attribute access. Python’s documentation describes the protocol with types involved described with words. Let’s look at how we can write those as type hints.Descriptor exemplumLet’s add type hints to this complete descriptor, which validates that allows only positive numbers: import math class PositiveFloat : def __set_name__ ( self , owner , name ) -> None : self . name = name def __get__ ( self , obj , objtype = None ): if obj is None : return self return obj . __dict__ [ self . name ] def __set__ ( self , obj , value ): if value < 0.0 or math . isnan ( value ) or math . isinf ( value ): raise ValueError ( f " { self . name } must be a positive real number." ) obj . __dict__ [ self . name ] = value def __delete__ ( self , obj ) -> None : if self . name not in obj . __dict__ : raise AttributeError ( self . name ) del obj . __dict__ [ self . name ] A quick reminder of all these methods:__set_name__() is called when our descriptor is assigned to a class variable. It allows our descriptor instance to known which name it has been assigned to.__get__() is called on attribute access. When accessed on the class, obj is None, in which case it’s normal to return the descriptor instance. When accessed on an instance, obj contains that instance. Our descriptor stores its value inside the instance __dict__, so its implementation is straightforward.__set__() is called on setting a new value for the attribute. obj is the instance.__delete__() is called on attribute deletion. obj is again the instance.Descriptors do not need to implement all of these methods. We’re only using a complete example here to cover all the type hints.We can check our descriptor in action with python -i example.py: >>> class Widget : ... rotations = PositiveFloat () ... >>> >>> widget = Widget () >>> # Test set, get, delete happy paths >>> widget . rotations = 12.0 >>> widget . rotations 12.0 >>> del widget . rotations >>> # Setting to an invalid value: >>> widget . rotations = - 1.0 Traceback (most recent call last): File "<stdin>" , line 1 , in <module> File "/.../example.py" , line 15 , in __set__ raise ValueError ( f " { self . name } must be a positive real number." ) ValueError : rotations must be a positive real number. >>> # Deleting when it’s already been deleted: >>> del widget . rotations Traceback (most recent call last): File "<stdin>" , line 1 , in <module> File "/.../example.py" , line 20 , in __delete__ raise AttributeError ( self . name ) AttributeError : rotations Okay great, now how about them type hints?Addimus Type HintsAlright, when we add type hints we get: from __future__ import annotations import math from typing import cast , overload class PositiveFloat : def __set_name__ ( self , owner : type [ object ], name : str ) -> None : self . name = name @overload def __get__ ( self , obj : None , objtype : None ) -> PositiveFloat : ... @overload def __get__ ( self , obj : object , objtype : type [ object ]) -> float : ... def __get__ ( self , obj : object | None , objtype : type [ object ] | None = None ) -> PositiveFloat | float : if obj is None : return self return cast ( float , obj . __dict__ [ self . name ]) def __set__ ( self , obj : object , value : float ) -> None : if value < 0.0 or math . isnan ( value ) or math . isinf ( value ): raise ValueError ( f " { self . name } must be a positive real number." ) obj . __dict__ [ self . name ] = value def __delete__ ( self , obj : object ) -> None : if self . name not in obj . __dict__ : raise AttributeError ( self . name ) del obj . __dict__ [ self . name ] Let’s look through this.In __set_name__(), the owner argument is the class that the descriptor is assigned to. We know it’s a class, but nothing more, so we can use type[object] to mean “any class derived from object”, that is “any class”. name is the name of the attribute, and we don’t need to return anythign.__get__() is the most complicated. It requires use of overload() to accurately cover the two ways it can be called. We spell out the two cases for calling on the class (obj: None) and the instance (obj: object). The implementation then unions the types for the two cases. We need to use cast() in our return statement, because Mypy cannot tell that the attribute in __dict__ must be a float. It assumes that a dynamic attribute fetch from __dict__ could be Any. This is a reasonable assumption, but from our descriptor’s implementation we know the value should be a float. (...unless some naughty code changes __dict__ directly). Without cast() we would get the error: example.py:24: error: Returning Any from function declared to return "Union[PositiveFloat, float]" Both __set__() and __delete__() are more straightforward. We declare that the instance may be any type, and that the set value must be a float.Phew.Limited owner classesThroughout the type hints we’ve used type[object] for the type our descriptor is attached to, and object for the type of the instance. This is because our descriptor doesn’t target attachment to any particular type.If we want to limit our descriptor’s attachment to particular types, we need only edit every type hint to swap object for that type. For example, imagine we introduce a class Validatable. We would edit the types in __set_name__() to read: def __set_name__ ( self , owner : type [ Validatable ], name : str ) -> None : ... …and similarly for the other methods.Then if we assigned our descriptor to a non-Validatable class: class Widget : rotations = PositiveFloat () widget = Widget () widget . rotations = 12.0 …Mypy would complain about the overload cases going unmatched: $ mypy --strict example.py example.py:46: error: No overload variant of "__get__" of "PositiveFloat" matches argument types "Widget", "Type[Widget]" example.py:46: note: Possible overload variants: example.py:46: note: def __get__(self, obj: None, objtype: None) -> PositiveFloat example.py:46: note: def __get__(self, obj: Validatable, objtype: Type[Validatable]) -> float example.py:46: error: Argument 1 to "__set__" of "PositiveFloat" has incompatible type "Widget"; expected "Validatable" Found 2 errors in 1 file (checked 1 source file) Note that Mypy doesn’t see any problem in the class definition, just when we use an instance.FinMay this scroll guide you in your Level 17+ Quest “Accurate Type Hints Everywhere”,—Adam

Let’s add type hints to this complete descriptor, which validates that allows only positive numbers: import math class PositiveFloat : def __set_name__ ( self , owner , name ) -> None : self . name = name def __get__ ( self , obj , objtype = None ): if obj is None : return self return obj . __dict__ [ self . name ] def __set__ ( self , obj , value ): if value < 0.0 or math . isnan ( value ) or math . isinf ( value ): raise ValueError ( f " { self . name } must be a positive real number." ) obj . __dict__ [ self . name ] = value def __delete__ ( self , obj ) -> None : if self . name not in obj . __dict__ : raise AttributeError ( self . name ) del obj . __dict__ [ self . name ]

Leave a Comment