31
loading...
This website collects cookies to deliver better user experience
What is the descriptor protocol?
How do I apply the descriptor protocol?
Close look at the descriptor API
Applications of the descriptor protocol.
Use descriptors and decorators to implements the @property
annotation
class Person:
name = "Mustermann"
def __init__(self):
self.first_name = "Max"
person = Person()
# read
person.name
person.first_name
# write
person.name = "Mareike"
person.first_name = "Maike"
# delete
del person.name
del person.first_name
Person
class contains two attributes.The class attribute name
The object instance attribute first_name
__dict__
. __dict__
variable is a dictionary. person = Person()
print(person.__dict__.keys())
print(Person.__dict__.keys())
dict_keys([])
dict_keys(['__module__', 'name', '__dict__', '__weakref__', '__doc__'])
__dict__
variable.name
class attribute in the output of Person.__dict__
and the first_name
object instance attribute in the output of person.__dict__
. __dict__
data structures to carry out the operations.getattr
, setattr
, and delattr
.person = Person()
# read
getattr(Person, "name")
getattr(person, "first_name")
# write
setattr(Person, "name", "Mareike")
setattr(person, "name", "Maike")
# delete
delattr(Person, "name")
delattr(person, "first_name")
__dict__
variables directly?# read
Person.__dict__["name"]
# write
Person.__dict__["name"] = "Mareike"
# delete
del Person.__dict__["name"]
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
<ipython-input-68-7ab3e836bd7a> in <module>
3
4 # write
----> 5 Person.__dict__["name"] = "Mareike"
6
7 # delete
TypeError: 'mappingproxy' object does not support item assignment
__dict__
data structure directly. getattr
, setattr
and delattr
access instance or class attributes.Person
example with a descriptor class attribute.import logging
logging.basicConfig(level=logging.INFO)
class MyStringField:
def __get__(self, obj, obj_type=None):
logging.info("__get__: self._name -> %r", obj._name)
return obj._name
def __set__(self, obj, value):
logging.info("__set__: self._name <- %r", value)
obj._name = value
def __delete__(self, obj):
logging.info("__delete__: self._name")
del obj._name
class Person:
name = MyStringField()
person = Person()
person.name = "Max Mustermann"
person.name
del person.name
INFO:root:__set__: self._name <- 'Max Mustermann'
INFO:root:__get__: self._name -> 'Max Mustermann'
INFO:root:__delete__: self._name
name
is a class attribute but it handles the access of the instance attribute _name
.class Person:
def __init__(self):
self.name = MyStringField()
person = Person()
person.name = "Max Mustermann"
person.name
del person.name
__get__
, __set__
and __delete__
do not get invoked.__get__
, __set__
and __delete__
is connected to the attribute access.getattr
, setattr
and delattr
invoke the descriptor methods if they exist. __dict__
data structures directly. __dict__
data structures directly would not invoke the descriptor methods of an attribute."""
Override read access behavior.
:param obj: reference to the object instance (nullable)
:param obj_type: reference to the object instance type (nullable)
"""
__get__(self, obj, obj_type=None) -> object
"""
Override write access behavior.
:param obj: @see __get__
:param value: the next value that the caller assigns
"""
__set__(self, obj, value) -> None
"""
Inform the Descriptor about its name.
E.g.
class MyDescriptor:
__set_name__(self, obj_type, name):
...
class Person:
first_name = MyDescriptor()
The call `name = MyDescriptor()` invokes
__set_name__ with the name="first_name".
:param obj: @see __get__
:param name: descriptor name
"""
__set_name(self, obj_type, name) -> None
"""
Override delete behavior
:param obj: @see __get__
"""
__delete__(self, obj) -> None
__set_name__
which we will explore in the next section.obj
and obj_type
parameters.obj
yields a reference to the object instance on which the descriptor got invoked whileobj_type
references the type.import logging
logging.basicConfig(level=logging.INFO)
class MyStringField:
def __get__(self, obj, obj_type=None):
logging.info("__get__: obj: %r, obj_type: %r", obj, obj_type)
return obj._name
def __set__(self, obj, value):
logging.info("__set__: obj: %r", obj)
obj._name = value
def __delete__(self, obj):
logging.info("__delete__: obj: %r", obj)
del obj._name
class Person:
name = MyStringField()
person = Person()
person.name = "Max Mustermann"
person.name
del person.name
INFO:root:__set__: obj: <__main__.Person object at 0x7f4bf024b7f0>
INFO:root:__get__: obj: <__main__.Person object at 0x7f4bf024b7f0>, obj_type: <class '__main__.Person'>
INFO:root:__delete__: obj: <__main__.Person object at 0x7f4bf024b7f0>
obj
references a person
object instance, while obj_type
references the Person
type.obj
and obj_type
parameters.__set_name__
descriptor method is there to make our descriptors more generic.import logging
class MyProperty:
def __init__(self, prop_type):
self._type = prop_type
def __set_name__(self, obj_type, name):
logging.info("__set_name__ with name: %r", name)
self.property_name = "_name"
def __get__(self, obj, obj_type=None):
value = getattr(obj, self.property_name)
logging.info("__get__: self.%r -> %r", self.property_name, value)
return value
def __set__(self, obj, value):
if type(value) is not self._type:
raise ValueError("expected type {} but got {} instead".format(self._type, type(value)))
setattr(obj, self.property_name, value)
logging.info("__set__: self.%r <- %r", self.property_name, value)
class Person:
name = MyProperty(prop_type=str)
first_name = MyProperty(prop_type=str)
person = Person()
person._name = "Mustermann"
person._first_name = 5
logging.info("attributes (after): %r", person.__dict__.keys())
logging.info("person.name: %r", person._name)
logging.info("person.first_name: %r", person._first_name)
INFO:root:__set_name__ with name: 'name'
INFO:root:__set_name__ with name: 'first_name'
INFO:root:attributes (after): dict_keys(['_name', '_first_name'])
INFO:root:person.name: 'Mustermann'
INFO:root:person.first_name: 5
MyProperty
saves the attribute name that we received through the __set_name__
call as an instance attribute._
which is a common practice to name private instance attributes in Python.Validator
attribute class that executes the validate
method before the attribute gets modified.import logging
import re
from abc import ABC, abstractmethod
logging.basicConfig(level=logging.INFO)
class ValidationException(Exception):
pass
class PropertyWithValidator(ABC):
def __set_name__(self, owner, name):
self.property_name = "_" + name
def __get__(self, obj, obj_type=None):
value = getattr(obj, self.property_name)
logging.info("__get__: %r -> %r", self.property_name, value)
return value
def __set__(self, obj, value):
self.validate(value)
logging.info("__set__: %r <- %r", self.property_name, value)
setattr(obj, self.property_name, value)
@abstractmethod
def validate(self, value):
pass
class IPv4(PropertyWithValidator):
def validate(self, value):
if type(value) is not str:
raise ValidationException("{} must be a string to be a valid ipv4 address".format(value))
ipv4_pattern = re.compile(r'^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$')
if not ipv4_pattern.match(value):
raise ValidationException("{} is not a valid ipv4 address".format(value))
class Flow:
src_ipv4 = IPv4()
dst_ipv4 = IPv4()
PropertyWithValidator
is an abstract class (@see ABC
) that invokes the abstract validate
method before you can assign a new value to the descriptor attribute.IPv4
to implement a concrete Validator class. re
to ensure that values meet the IPv4 standard.flow = Flow()
logging.info("attributes: %r", flow.__dict__.keys())
flow.src_ipv4 = "2.2.2.2"
flow.dst_ipv4 = "5.5.5.5"
flow.src_ipv4
flow.dst_ipv4
INFO:root:attributes: dict_keys([])
INFO:root:__set__: '_src_ipv4' <- '2.2.2.2'
INFO:root:__set__: '_dst_ipv4' <- '5.5.5.5'
INFO:root:__get__: '_src_ipv4' -> '2.2.2.2'
INFO:root:__get__: '_dst_ipv4' -> '5.5.5.5'
'5.5.5.5'
flow.src_ipv4
and flow.dst_ipv4
.flow.src_ipv4 = "5.5.5."
---------------------------------------------------------------------------
ValidationException Traceback (most recent call last)
<ipython-input-53-86168850f280> in <module>
----> 1 flow.src_ipv4 = "5.5.5."
<ipython-input-48-f8c1cbfc54dd> in __set__(self, obj, value)
20
21 def __set__(self, obj, value):
---> 22 self.validate(value)
23 logging.info("__set__: %r <- %r", self.property_name, value)
24 setattr(obj, self.property_name, value)
<ipython-input-48-f8c1cbfc54dd> in validate(self, value)
38 ipv4_pattern = re.compile(r'^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$')
39 if not ipv4_pattern.match(value):
---> 40 raise ValidationException("{} is not a valid ipv4 address".format(value))
41
42 class Flow:
ValidationException: 5.5.5. is not a valid ipv4 address
from abc import ABC, abstractmethod
class UnauthorizedAccessException(Exception):
REASON_UNAUTHORIZED_CALLER = "Caller is not authorized to perform {operation} operation on attribute {attribute}"
class ProtectedAttribute(ABC):
def __set_name__(self, obj, name):
self._property_name = "_" + name
def __get__(self, obj, obj_type=None):
if not self.is_access_authorized(obj):
self._raise_unauthorized("read")
return getattr(obj, self._property_name)
def __set__(self, obj, value):
if not self.is_access_authorized(obj):
self._raise_unauthorized("write")
setattr(obj, self._property_name, value)
def _raise_unauthorized(self, operation):
raise UnauthorizedAccessException(
UnauthorizedAccessException.REASON_UNAUTHORIZED_CALLER.format(
operation=operation,
attribute=self._property_name
)
)
@abstractmethod
def is_access_authorized(self, obj):
pass
class SecurityContext:
current_session = { "user_name": "Max Mustermann" }
class Password(ProtectedAttribute):
def __init__(self, authorized_users):
self._authorized_users = authorized_users
def is_access_authorized(self, obj):
return SecurityContext.current_session["user_name"] in self._authorized_users
class User:
password = Password(authorized_users=["Maike Mareike"])
__set__
and __get__
descriptor methods invoke the abstract is_access_authorized
method before accessing the descriptor attribute value.user = User()
user.password = "my_secret"
---------------------------------------------------------------------------
UnauthorizedAccessException Traceback (most recent call last)
<ipython-input-80-b925f049f61b> in <module>
1 user = User()
2
----> 3 user.password = "my_secret"
<ipython-input-79-2e1ac42e3056> in __set__(self, obj, value)
19
20 if not self.is_access_authorized(obj):
---> 21 self._raise_unauthorized("write")
22
23 setattr(obj, self._property_name, value)
<ipython-input-79-2e1ac42e3056> in _raise_unauthorized(self, operation)
24
25 def _raise_unauthorized(self, operation):
---> 26 raise UnauthorizedAccessException(
27 UnauthorizedAccessException.REASON_UNAUTHORIZED_CALLER.format(
28 operation=operation,
UnauthorizedAccessException: Caller is not authorized to perform write operation on attribute _password
private
to encapsulate your code access.is_access_authorized
method while accessing the user.password
field.setattr
and getattr
implementation for the Password
class at runtime.)Configuration
attribute class. The Configuration
gets represented as a dictionary data structure in memory, and as a JSON file on the file system.Configruation
attribute class is to keep it's value in memory in sync with the JSON file on disk.import json
import logging
logging.basicConfig(level=logging.INFO)
class JSONConfiguration:
def __init__(self, file_path):
self._file_path = file_path
def __set_name__(self, obj, name):
self._property_name = "_" + name
def _write_to_disk(self, config_dict):
logging.info("write config back to disk on path %r", self._file_path)
json_str = json.dumps(config_dict)
with open(self._file_path) as config_file:
confoig_file.write(json_str)
def __get__(self, obj, obj_type=None):
return getattr(obj, self._property_name)
def __set__(self, obj, value):
setattr(obj, self._property_name, value)
self._write_to_disk(value)
class Application:
config = JSONConfiguration(
file_path="/tmp/config.json",
)
Application.config
object behaves like a dictionary from the perspective of the caller. _write_to_disk
method that gets invoked by the __set__
descriptor method. Application.config
attribute and the caller._write_to_disk
method after modifying the config dictionary data structure.application = Application()
application.config = { "host": "localhost", "port": "8088"}
INFO:root:write config back to disk on path '/tmp/config.json'
---------------------------------------------------------------------------
FileNotFoundError Traceback (most recent call last)
<ipython-input-70-0ccf8f4311f0> in <module>
1 application = Application()
----> 2 application.config = { "host": "localhost", "port": "8088"}
<ipython-input-64-06078f3aa6fa> in __set__(self, obj, value)
25 def __set__(self, obj, value):
26 setattr(obj, self._property_name, value)
---> 27 self._write_to_disk(value)
28
29
<ipython-input-64-06078f3aa6fa> in _write_to_disk(self, config_dict)
17 json_str = json.dumps(config_dict)
18
---> 19 with open(self._file_path) as config_file:
20 confoig_file.write(json_str)
21
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/config.json'
FileNotFoundError
exception.)@popular
annotation can be implemented through the descriptor and decorator protocols.import logging
logging.basicConfig(level=logging.INFO)
class prop:
def __init__(self, fget=None, fset=None, fdel=None):
self._fget = fget
self._fset = fset
self._fdel = fdel
def __get__(self, obj, obj_type):
value = self._fget(obj)
logging.info("read: {}".format(value))
return value
def __set__(self, obj, value):
logging.info("write: {}".format(value))
self._fset(obj, value)
def __del__(self, obj):
logging.info("del")
self._fdel(obj)
def __call__(self, fget):
return type(self)(fget, self._fset, self._fdel)
def setter(self, fset):
return type(self)(self._fget, fset, self._fdel)
def deleter(self, fdel):
return type(self)(self._fget, self._fset, fdel)
class Person:
@prop
def name(self):
return self._name
@name.setter
def set_name(self, value):
self._name = value
@name.deleter
def delete_name(self):
del self._name
@prop
def first_name(self):
return self._first_name
@first_name.setter
def set_first_name(self, value):
self._first_name = value
@first_name.deleter
def delete_first_name(self):
del self._first_name
__init__
method gets called when you annotate the @property
decorator. __init__
method are the parameters that you pass to the decorator call.@property
descriptor requires getter
, setter
, and deleter
methods for our property.@property(fget=..., fset=..., fdel=...)
.getter
, setter
and deleter
separately to make our API even more elegant. None
as the default parameter for our fget
, fset
, and fdel
methods. fget
, fset
, and fdel
with real methods.__call__
method gets invoked when the decorator gets called. The __call__
method takes a method as an argument (in our case the fget
=> getter method) and returns a method again.__call__
methods returns a new decorator that uses fget
as getter method. The linetype(self)(fget, self._fset, self._fdel)
returns the decorator instance itself while adding the fget
method.fget
, fset
, and fdel
methods.person = Person()
person.set_name = "Max"
person.set_first_name = "Mustermann"
logging.info("attributes: %r", person.__dict__.keys())
INFO:root:write: Max
INFO:root:write: Mustermann
INFO:root:attributes: dict_keys(['_name', '_first_name'])
getattr
, setattr
, and delattr
and explained how they relate to the Python __dict__
data structure and to the descriptor protocol.@property
annotation can be implemented through a synergy of the decorator and descriptor protocol.