Tags

, , ,

Python has the useful notion of descriptor objects, which give object attributes control over how they are accessed. Descriptors enable calculated-on-the-fly attribute values and can prevent or control modification of data values.

The previous post covered the basics. In this post, I’ll dig deeper into Python descriptors with some more involved examples. This post assumes the basics covered in the previous post.

In previous examples, the get, set, and delete methods were in the class defining the data attributes (or in a subclass). I’ll call such attributes managed. We needed all three methods for each fully managed attribute. A project with many classes, each with managed data attributes, ends up with a lot of duplicated code. It would be nice to have a general descriptor class (something like what was indicated in the first examples in the last post):

001| class myDescriptor:
002|     ”’Non-descript Descriptor class.”’
003| 
004|     def __get__ (self, obj, cls):
005|         which = ‘object’ if obj else ‘class’
006|         print(‘%s.get()’ % which)
007|         return None
008| 
009|     def __set__ (self, obj, val):
010|         print(‘set(%s)’ % val)
011| 
012|     def __delete__ (self, obj):
013|         print(‘Nope!’)
014| 
015| class myClass:
016|     x = myDescriptor()
017|     y = myDescriptor()
018| 
019| my_instance = myClass()
020| print(my_instance.x, my_instance.y)
021| print()
022| 
023| my_instance.x = 42
024| my_instance.y = 21
025| print(my_instance.x, my_instance.y)
026| print()
027| 
028| del my_instance.x
029| del my_instance.y
030| 

Which works fine in form, but what are the get, set, and delete methods acting on? As shown here they have no knowledge of the class using them, nor of the attributes of instances of that class. When run, this just prints:

object.get()
object.get()
None None

set(42)
set(21)
object.get()
object.get()
None None

Nope!
Nope!

The descriptor methods get called but are blind to what owns them. We need a bigger code.

§

One approach a descriptor class can take is to make assumptions about the class using it. It can access an attribute it expects to exist. Of course, that restricts its use to classes with the right attributes, but that may not be an issue if you have related classes that need the same managed attributes.

Here’s an example of a descriptor that assumes the _x and _y attributes from the PointBaseClass from the previous post:

001| class DependentProperty:
002|     ”’Dependent property knows about the class that uses it.”’
003| 
004|     def __init__ (self, default=(0,0)):
005|         ”’New instance.”’
006|         self.default = default
007| 
008|     def __get__ (self, obj, cls):
009|         ”’Get property value.”’
010|         if obj:
011|             return (obj._x, obj._y)
012|         return self.default
013| 
014|     def __set__ (self, obj, value):
015|         ”’Set property value.”’
016|         obj._x = value[0]
017|         obj._y = value[1]
018| 
019|     def __delete__ (self, obj):
020|         ”’Reset Property to default.”’
021|         obj._x = self.default[0]
022|         obj._y = self.default[1]
023| 
024| class DependentPropertyExample (PointBaseClass):
025|     xy = DependentProperty((1,1))
026| 
027| 
028| obj = DependentPropertyExample(21, 42)
029| print(DependentPropertyExample.xy, ‘\n’)
030| print(‘obj: %s’ % obj)
031| print(‘obj.xy=(%d,%d)’ % obj.xy)
032| print(‘obj._x=%s obj._y=%s\n’ % (obj._x, obj._y))
033| 
034| obj.xy = (86, 99)
035| print(‘obj.xy=(%d,%d)\n’ % obj.xy)
036| 
037| del obj.xy
038| print(‘obj.xy=(%d,%d)’ % obj.xy)
039| 

One big change from previous examples is the __init__ method (lines #4-#6) that takes a parameter (expected to be a pair of numbers) and uses it to set an attribute, default.

The __get__ method (lines #8-#12) returns the default parameter when called by the class but returns both the assumed _x and _y attributes in a tuple if called by the object.

The __delete__ method (lines #19-#22) resets the assumed _x and _y attributes to the default value. Previously, we used the delete operation to clear the attribute’s value to zero, but here we can provide a default other than zero (note the default is still zero if no alternate value is provided).

The DependentPropertyExample class (lines #24 and #25) is derived from PointBaseClass (see previous post) and inherits the _x and _y attributes from it. The example class uses DependentProperty to define a single descriptor attribute xy and sets the default value to (-1, -1).

When run, this prints:

(-1, -1) 

obj: (21.000000, 42.000000)
obj.xy=(21,42)
obj._x=21 ._y=42

obj.xy=(86,99)

obj.xy=(-1,-1)

This is a step in the right direction, but we can do a lot more.

§

Another approach is to put the data in the descriptor, but this only works for data shared by all class instances. (Remember, descriptors are class attributes.) But sometimes classes do share data with all their instances. Here’s an example:

001| class ConfiguredProperty:
002|     ”’Descriptor class for configurable attributes.”’
003|     modes = [,‘str’,‘hex’,‘oct’,‘bin’,‘float’,‘cmplx’,‘raw’]
004| 
005|     def __init__ (self, data, mode=‘str’):
006|         if mode not in ConfiguredProperty.modes:
007|             raise ValueError(‘Illegal mode: “%s”‘ % mode)
008| 
009|         self._data = data
010|         self._mode = mode.lower()
011| 
012|     def __get__ (self, obj, cls):
013|         if obj is None:
014|             return self._mode
015| 
016|         if self._mode == ‘str’: return str(self._data)
017|         if self._mode == ‘hex’: return (‘%x’ % self._data)
018|         if self._mode == ‘oct’: return (‘%o’ % self._data)
019|         if self._mode == ‘bin’: return (‘%s’ % bin(self._data)[2:])
020|         if self._mode == ‘oct’: return (‘%o’ % self._data)
021| 
022|         if self._mode == ‘float’:
023|             return (‘%.6f’ % self._data)
024| 
025|         if self._mode == ‘cmplx’:
026|             z = self._data
027|             return (‘[%+6.3f, %+6.3fi]’ % (z.real, z.imag))
028| 
029|         # Return raw data…
030|         return self._data
031| 
032|     def __set__ (self, obj, value):
033|         if value not in ConfiguredProperty.modes:
034|             raise ValueError(‘Illegal mode: “%s”‘ % value)
035| 
036|         # Set mode to a (valid!) new value…
037|         self._mode = value
038| 
039|     def __delete__ (self, obj):
040|         # Set mode to default…
041|         self._mode = ‘str’
042| 
043| class ConfiguredPropertyExample:
044|     ”’Class using configured attributes.”’
045| 
046|     a = ConfiguredProperty(21, mode=‘hex’)
047|     b = ConfiguredProperty(‘local’)
048|     c = ConfiguredProperty(3.14159, mode=‘float’)
049|     d = ConfiguredProperty(complex(1,1), mode=‘cmplx’)
050| 
051| 
052| sq = lambda x: (‘”%s”‘ % x) if isinstance(x,str) else x
053| obj = ConfiguredPropertyExample()
054| 
055| print(‘a:%s = %s’ % (ConfiguredPropertyExample.a, sq(obj.a)))
056| print(‘b:%s = %s’ % (ConfiguredPropertyExample.b, sq(obj.b)))
057| print(‘c:%s = %s’ % (ConfiguredPropertyExample.c, sq(obj.c)))
058| print(‘d:%s = %s’ % (ConfiguredPropertyExample.d, sq(obj.d)))
059| print()
060| 
061| fmt = ‘a:%s = %s’
062| obj.a = ‘raw’
063| print(fmt % (ConfiguredPropertyExample.a, sq(obj.a)))
064| obj.a = ‘bin’
065| print(fmt % (ConfiguredPropertyExample.a, sq(obj.a)))
066| obj.a = ‘hex’
067| print(fmt % (ConfiguredPropertyExample.a, sq(obj.a)))
068| obj.a = ‘float’
069| print(fmt % (ConfiguredPropertyExample.a, sq(obj.a)))
070| print()
071| 
072| del obj.a
073| print(fmt % (ConfiguredPropertyExample.a, sq(obj.a)))
074| obj.a = ‘hex’
075| print(fmt % (ConfiguredPropertyExample.a, sq(obj.a)))
076| print()
077| 
078| 

The idea is that the (class) data can be anything desired but is configured as to how that data appears when accessed (modes include hex string, binary string, and float object). Setting the data value sets the data mode — which requires a valid mode name. Invalid mode names raise an error. Deleting the data value resets the data mode to the default (string).

As with the previous example, the descriptor class defines __init__ (lines #5-#10) to initialize the descriptor object. The arguments are the data the descriptor is to own and an optional mode name (the default mode is string).

The __get__ method (lines #12-#30) returns the current mode when called on the class. When called on the instance, it returns its data in the form specified by _mode.

The __set__ method (lines #32-#37) allows class clients to change the mode. Mode names supplied here (or for the constructor) must be valid. They raise an exception if not recognized. Note that the empty string and the string “raw” are recognized as valid but the __get__ function doesn’t use them. These modes just drop through to line #30, which returns the raw data.

The __delete__ method (lines #39-#41) resets the mode to the default (string).

Using this descriptor, the ConfiguredPropertyExample class (lines #43-#49) defines four data attributes, a, b, c, and d.

The code in lines #52-#76 exercises an instance of the example class. When run, it prints:

a:hex = "15"
b:str = "local"
c:float = "3.141590"
d:cmplx = "[+1.000, +1.000i]"

a:raw = 21
a:bin = "10101"
a:hex = "15"
a:float = "21.000000"

a:str = "21"
a:hex = "15"

This is fine for class data, but we’d also like to manage instance data. That requires the descriptor to know about the instance data. So far, to accomplish that, we’ve put the descriptor functions inside the class that uses them (in the previous post) and here by making assumptions or by completely ignoring instance attributes. But there is another piece to this puzzle.

§

There is one more method to know about when it comes to descriptor definitions: __set_name__. Python calls this when the class attribute is created (after it calls the __init__ method). Note that this is when Python compiles the class that uses a descriptor. As part of creating the class dictionary, Python calls both the __init__ and __set_name__ methods.

The name Python passes to the __set_name__ method is the name of the class attribute being created. We can use this to tell us what variable to access in class instances:

001| class SimpleProperty:
002|     ”’Simple property descriptor.”’
003| 
004|     def __init__ (self, default):
005|         self.name = ‘<new>’
006|         self.default = default
007| 
008|     def __str__ (self):
009|         return ‘(%s: %s)’ % (self.name, self.default)
010| 
011|     def __set_name__ (self, owner, name):
012|         ”’Set attribute name.”’
013|         self.name = ‘_%s’ % name
014| 
015|     def __get__ (self, obj, owner):
016|         ”’Get attribute value (or name).”’
017|         return obj.__dict__[self.name] if obj else self.default
018| 
019|     def __set__ (self, obj, value):
020|         ”’Set attribute value.”’
021|         obj.__dict__[self.name] = value
022| 
023|     def __delete__ (self, obj):
024|         ”’Reset attribute value.”’
025|         obj.__dict__[self.name] = self.default
026| 
027| 
028| class SimplePropertyExample:
029|     p1 = SimpleProperty(42)
030|     p2 = SimpleProperty(6.28318)
031|     p3 = SimpleProperty(‘user’)
032|     p4 = SimpleProperty(‘/info’)
033| 
034|     def __init__ (self, name, p1=None, p2=None, p3=None, p4=None):
035|         self.name = name
036|         self._p1 = SimplePropertyExample.p1 if p1 is None else p1
037|         self._p2 = SimplePropertyExample.p2 if p2 is None else p2
038|         self._p3 = SimplePropertyExample.p3 if p3 is None else p3
039|         self._p4 = SimplePropertyExample.p4 if p4 is None else p4
040| 
041|     def __str__ (self):
042|         return (‘%s:%s’ % (self.name, self.__class__.__name__))
043| 
044|     def __repr__ (self):
045|         return ‘{%s %012x}’ % (self.__class__.__name__, id(self))
046| 
047| 
048| print(‘=== class:%s (default) values ===’ % SimplePropertyExample.__name__)
049| print(‘p1=’,SimplePropertyExample.p1)
050| print(‘p2=’,SimplePropertyExample.p2)
051| print(‘p3=’,SimplePropertyExample.p3)
052| print(‘p4=’,SimplePropertyExample.p4, ‘\n’)
053| 
054| b1 = SimplePropertyExample(‘pooh’, 68, 2.71828)
055| print(‘=== Object %s values ===’ % b1)
056| print(‘p1=’,b1.p1)
057| print(‘p2=’,b1.p2)
058| print(‘p3=’,b1.p3)
059| print(‘p4=’,b1.p4, ‘\n’)
060| 
061| b2 = SimplePropertyExample(‘bear’, 86, 42.21, ‘root’, ‘/debug’)
062| print(‘=== Object %s values ===’ % b2)
063| print(‘p1=’,b2.p1)
064| print(‘p2=’,b2.p2)
065| print(‘p3=’,b2.p3)
066| print(‘p4=’,b2.p4, ‘\n’)
067| 
068| print(‘Set b1 properties…’)
069| b1.p1 = 3.14159
070| b1.p2 = 99
071| b1.p3 = ‘piglet’
072| b1.p4 = complex(0, 1)
073| print(‘=== %s values ===’ % b1)
074| print(‘p1=’,b1.p1)
075| print(‘p2=’,b1.p2)
076| print(‘p3=’,b1.p3)
077| print(‘p4=’,b1.p4, ‘\n’)
078| 
079| print(‘Copy default properties to b2…’)
080| b2.p1 = SimplePropertyExample.p1
081| b2.p2 = SimplePropertyExample.p2
082| b2.p3 = SimplePropertyExample.p3
083| b2.p4 = SimplePropertyExample.p4
084| print(‘=== %s values ===’ % b2)
085| print(‘p1=’,b2.p1)
086| print(‘p2=’,b2.p2)
087| print(‘p3=’,b2.p3)
088| print(‘p4=’,b2.p4, ‘\n’)
089| 
090| print(‘Delete b1 properties…’)
091| del b1.p1
092| del b1.p2
093| del b1.p3
094| del b1.p4
095| print(‘=== %s values ===’ % b1)
096| print(‘p1=’,b1.p1)
097| print(‘p2=’,b1.p2)
098| print(‘p3=’,b1.p3)
099| print(‘p4=’,b1.p4, ‘\n’)
100| 

In the __init__ method (lines #4-#6), the descriptor takes a single parameter, a default value. It also sets a name attribute to “<new>” (just to give it an obvious default value).

The __set_name__ method (lines #11-#13) receives the name the owner class is using for the new descriptor object. The owner parameter is the class that owns this new managed attribute. The code adds an underbar to the name parameter to build the name of the expected instance attribute. If the class attribute created using this descriptor is named foo, then the matching instance attribute must be named _foo.

The __get__ method (lines #15-#20), if called on the class returns the default value as if this was a class attribute. If called on an instance, it uses the computed name (with the leading underbar) to access the attribute in object’s dictionary.

The __set__ method (lines #19-#21) also uses the computed name to access the attribute but sets its value with the passed value. (We’re not doing any constraining in this example, just allowing set/get access (but not delete!) to a “private” instance variable.

The __delete__ method (lines #23-#25) resets the instance attribute’s value to the provided default (again using the computed name).

The SimplePropertyExample (lines #28-#45) uses the SimpleProperty class to define four managed data attributes: p1, p2, p3, and p4. The __str__ and __repr__ methods (lines #41-##45) just return appropriate strings.

The SimplePropertyExample.__init__ method (lines #34-#39) takes a required name and four optional parameters. The method defines four instance attributes to match the four class attributes: _p1, _p2, _p3, and _p4. If an input parameter is provided (not set to None) the instance attribute uses that value, otherwise it uses the default value from the class attribute.

The code in lines #48-#99 exercises the SimplePropertyExample class and two instances, b1 and b2. Lines #68-#77 demonstrate setting values on the instance attributes. Lines #79-#88 demonstrate manually copying the default values from the class attributes to the instance attributes, and lines #90-#99 demonstrate accomplishing the same thing by deleting the attributes.

When run, the code prints:

=== class:SimplePropertyExample (default) values ===
p1= 42
p2= 6.28318
p3= user
p4= /info

=== Object pooh:SimplePropertyExample values ===
p1= 68
p2= 2.71828
p3= user
p4= /info

=== Object bear:SimplePropertyExample values ===
p1= 86
p2= 42.21
p3= root
p4= /debug

Set b1 properties...
=== pooh:SimplePropertyExample values ===
p1= 3.14159
p2= 99
p3= piglet
p4= 1j

Copy default properties to b2...
=== bear:SimplePropertyExample values ===
p1= 42
p2= 6.28318
p3= user
p4= /info

Delete b1 properties...
=== pooh:SimplePropertyExample values ===
p1= 42
p2= 6.28318
p3= user
p4= /info

§

And that’s about it for descriptors. (As always, I’m happy to answer questions about the content if anything isn’t clear or you’d like something explained in more detail.) I’ll end with a heavily instrumented version of a descriptor you can use for experimentation:

001| typename = lambda obj: type(obj).__name__
002| 
003| class InstrumentedProperty:
004|     ”’Instrumented property class.”’
005| 
006|     def __init__ (self, config=(100,+100)):
007|         ”’New InstrumentedProperty property.”’
008|         t = (typename(self), id(self), config)
009|         print(‘{%s @%012x} init: %s’ % t)
010| 
011|         # Initialize instance…
012|         self.name = ‘{new}’
013|         self.config = config
014| 
015|     def __set_name__ (self, owner, name):
016|         ”’Set Property name.”’
017|         t = (typename(self), id(self), name, owner.__name__, id(owner))
018|         print(‘{%s @%012x} set-name: %s on {%s @%012x}’ % t)
019| 
020|         # Set property name…
021|         self.name = ‘_%s’ % name
022| 
023|     def __set__ (self, obj, value):
024|         ”’Set Property value. Sets property configuration”’
025|         t = (typename(self), id(self), value, typename(obj), id(obj))
026|         print(‘{%s @%012x} set: %s {%s @%012x}’ % t)
027| 
028|         # Check if the value is in range…
029|         if self.config[0] <= value <= self.config[1]:
030|             obj.__dict__[self.name] = value
031|             return
032| 
033|         # Error, not in range…
034|         raise ValueError(‘Invalid value for %s: %s’ % (self.name,value))
035| 
036|     def __get__ (self, obj, cls):
037|         ”’Get Property value.”’
038|         if obj is None:
039|             caller = ‘{null}’
040|         else:
041|             caller = (‘{%s @%012x}’ % (typename(obj), id(obj)))
042|         t = (typename(self), id(self), caller, cls.__name__, id(cls))
043|         print(‘{%s @%012x} get: %s{%s @%012x}’ % t)
044| 
045|         # If called on instance, return the parameter value…
046|         if obj:
047|             return obj.__dict__[self.name]
048| 
049|         # Otherwise called on class, return the configuration…
050|         return self.config
051| 
052|     def __delete__ (self, obj):
053|         ”’Reset Property return mode to default.”’
054|         t = (typename(self), id(self), typename(obj), id(obj))
055|         print(‘{%s @%012x} delete: {%s @%012x}’ % t)
056| 
057|         # Reset the value to zero…
058|         obj.__dict__[self.name] = 0
059| 
060|     def __str__ (self):
061|         ”’Return a nice string version.”’
062|         return ‘%s: {%s}’ % (typename(self), self.config)
063| 
064| 
065| class InstrumentedPropertyExample:
066|     x = InstrumentedProperty()
067|     y = InstrumentedProperty()
068| 
069|     def __init__ (self, x=0, y=0):
070|         self._x = x
071|         self._y = y
072| 
073|     def __call__ (self): return (self.x, self.y)
074|     def __str__ (self): return ‘(%s, %s)’ % (self.x, self.y)
075|     def __repr__ (self): return ‘{%s @%012x}’ % (typename(self), id(self))
076| 
077| 
078| p0 = InstrumentedPropertyExample()
079| p1 = InstrumentedPropertyExample(21, 42)
080| p2 = InstrumentedPropertyExample(1, 1)
081| print()
082| print(‘Property configurations’)
083| print(InstrumentedPropertyExample.x)
084| print(InstrumentedPropertyExample.y, ‘\n’)
085| print(‘Initialized:’)
086| print(‘%s %s %s\n’ % (p0, p1, p2))
087| try: p1.x = 200
088| except Exception as e:
089|     print(‘ERR:’, e, ‘\n’)
090| p1.x = +99
091| p1.y = 99
092| p2.x = +100
093| p2.y = 100
094| print(‘%5s %5s %5s\n’ % (p0, p1, p2))
095| 
096| print(‘del p2 .x and .y:’)
097| del p2.x
098| del p2.y
099| print(‘%5s %5s %5s\n’ % (p0, p1, p2))
100| 

This descriptor works much the one from last time that took a configuration that limited the range the instance attribute could have. This __init__ method expects a tuple with two range parameters, a minimum and a maximum. As usual, calls on the class return this configuration. Attempts to set the attribute to values outside the range raise an exception. Deleting the attribute resets it to zero.

Note that, since the __init__ method has a print statement (and so does the __set_name__ method), using this descriptor generates print output when the class that creates its attributes. That is, when the class is compiled. That may cause unexpected output.

§ §

Descriptors are a powerful mechanism for creating rich data attributes for your objects. Hopefully these two posts have illustrated their value and provided some examples showing how to use them. For a more detailed (and official) tutorial, see the Python Descriptor HowTo Guide.

Ø