Reputation: 45231
In a descriptor, the second argument to __get__
and __set__
is bound to the calling object instance (and the third argument to __get__
is bound to the calling owner class object):
class Desc():
def __get__(self,instance,owner):
print("I was called by",str(instance),"and am owned by",str(owner))
return self
class Test():
desc = Desc()
t = Test()
t.desc
How would I go about creating a decorator to bind the second argument of another descriptor method (other than __get__
, __set__
, or __delete__
) to the instance object?
Example (just an example; not something I'm actually trying to do):
class Length(object):
'''Descriptor used to manage a basic unit system for length'''
conversion = {'inches':1,'centimeters':2.54,'feet':1/12,'meters':2.54/100}
def __set__(self,instance,length):
'''length argument is a tuple of (magnitude,unit)'''
instance.__value = length[0]
instance.__units = length[1]
def __get__(self,instance,owner):
return self
@MagicalDecoratorOfTruth
def get_in(self, instance, unit): #second argument is bound to instance object
'''Returns the value converted to the requested units'''
return instance.__value * (self.conversion[units] / self.conversion[instance.__units])
class Circle(object):
diameter = Length()
def __init__(self,diameter,units):
Circle.diameter.__set__((diameter,units))
c = Circle(12,'inches')
assert c.diameter.get_in('feet') == 1
c.diameter = (1,'meters')
assert c.diameter.get_in('centimeters') == 100
One way I have considered trying is wrapping the get_in
method with a decorator. Something similar is done using the @classmethod decorator, where the first argument of a class method is bound to the class object instead of the class instance object:
class Test():
@classmethod
def myclassmethod(klass):
pass
t = Test()
t.myclassmethod()
However, I'm unsure how to apply this to the case above.
A way to avoid the whole problem would be to pass the instance object to the descriptor method explicitly:
c = Circle(12,'inches')
assert c.diameter.get_in(c,'feet') == 1
c.diameter = (1,'meters')
assert c.diameter.get_in(c,'centimeters') == 100
However, this seems to violate D.R.Y., and is really ugly to boot.
Upvotes: 1
Views: 772
Reputation: 45231
Thanks to prpl.mnky.dshwshr's help, I was able to vastly improve this entire approach (and learn a lot about descriptors in the process).
class Measurement():
'''A basic measurement'''
def __new__(klass,measurement=None,cls_attr=None,inst_attr=None,conversion_dict=None):
'''Optionally provide a unit conversion dictionary.'''
if conversion_dict is not None:
klass.conversion_dict = conversion_dict
return super().__new__(klass)
def __init__(self,measurement=None,cls_attr=None,inst_attr=None,conversion_dict=None):
'''If object is acting as a descriptor, the name of class and
instance attributes associated with descriptor data are stored
in the object instance. If object is not acting as a descriptor,
measurement data is stored in the object instance.'''
if cls_attr is None and inst_attr is None and measurement is not None:
#not acting as a descriptor
self.__measurement = measurement
elif cls_attr is not None and inst_attr is not None and measurement is None:
#acting as a descriptor
self.__cls_attr = cls_attr
self.__inst_attr = inst_attr
#make sure class and instance attributes don't share a name
if cls_attr == inst_attr:
raise ValueError('Class and Instance attribute names cannot be the same.')
else:
raise ValueError('BOTH or NEITHER the class and instance attribute name must be or not be provided. If they are not provided, a measurement argument is required.')
##Descriptor only methods
def __get__(self,instance,owner):
'''The measurement is returned; the descriptor itself is
returned when no instance supplied'''
if instance is not None:
return getattr(instance,self.__inst_attr)
else:
return self
def __set__(self,instance,measurement):
'''The measurement argument is stored in inst_attr field of instance'''
setattr(instance,self.__inst_attr,measurement)
##Other methods
def get_in(self,units,instance=None):
'''The magnitude of the measurement in the target units'''
#If Measurement is not acting as a descriptor, convert stored measurement data
try:
return convert( self.__measurement,
units,
self.conversion_dict
)
except AttributeError:
pass
#If Measurement is acting as a descriptor, convert associated instance data
try:
return convert( getattr(instance,self.__inst_attr),
units,
getattr(type(instance),self.__cls_attr).conversion_dict
)
except Exception:
raise
def to_tuple(self,instance=None):
try:
return self.__measurement
except AttributeError:
pass
return getattr(instance,self.inst_attr)
class Length(Measurement):
conversion_dict = {
'inches':1,
'centimeters':2.54,
'feet':1/12,
'meters':2.54/100
}
class Mass(Measurement):
conversion_dict = {
'grams':1,
'pounds':453.592,
'ounces':453.592/16,
'slugs':453.592*32.1740486,
'kilograms':1000
}
def convert(measurement, units, dimension_conversion = None):
'''Returns the magnitude converted to the requested units
using the conversion dictionary in the provide dimension_conversion
object, or using the provided dimension_conversion dictionary.
The dimension_conversion argument can be either one.'''
#If a Measurement object is provided get measurement tuple
if isinstance(measurement,Measurement):
#And if no conversion dictionary, use the one in measurement object
if dimension_conversion is None:
dimension_conversion = measurement.conversion_dict
measurement = measurement.to_tuple()
#Use the dimension member [2] of measurement tuple for conversion if it's there
if dimension_conversion is None:
try:
dimension_conversion = measurement[2]
except IndexError:
pass
#Get designated conversion dictionary
try:
conversion_dict = dimension_conversion.conversion_dict
except AttributeError:
conversion_dict = dimension_conversion
#Get magnitude and units from measurement tuple
try:
meas_mag = measurement[0]
meas_units = measurement[1]
except (IndexError,TypeError):
raise TypeError('measurement argument should be indexed type with magnitude in measurement[0], units in measurement[1]') from None
#Finally perform and return the conversion
try:
return meas_mag * (conversion_dict[units] / conversion_dict[meas_units])
except IndexError:
raise IndexError('Starting and ending units must appear in dimension conversion dictionary.') from None
class Circle():
diameter = Length(cls_attr='diameter',inst_attr='_diameter')
def __init__(self,diameter):
self.diameter = diameter
class Car():
mass = Mass(cls_attr='mass',inst_attr='_mass')
def __init__(self,mass):
self.mass = mass
c = Circle((12,'inches'))
assert convert(c.diameter,'feet',Length) == 1
assert Circle.diameter.get_in('feet',c) == 1
assert c.diameter == (12,'inches')
d = Circle((100,'centimeters',Length))
assert convert(d.diameter,'meters') == 1
assert Circle.diameter.get_in('meters',d) == 1
assert d.diameter == (100,'centimeters',Length)
x = Length((12,'inches'))
assert x.get_in('feet') == 1
assert convert(x,'feet') == 1
Upvotes: 1
Reputation: 77424
There is a hook left in the Descriptor protocol for this sort of thing -- namely, when the Descriptor object is accessed from the class level, the value of instance
will be None
.
It's useful to think about this in the reverse direction. Let's start with Circle
:
class Circle(object):
diameter = Length()
def __init__(self, diameter, units):
self.diameter = (diameter, units)
Notice that instead of trying to manually call __set__
or call things from the class level (e.g. by invoking from Circle
directly) -- I am just using the descriptor as it is intended, simply setting a value.
Now, for the descriptor, virtually everything will be the same. I cleaned up the code style for the conversion dict
.
But for __get__
I add the extra check for whenever instance == None
. This will be the case whenever Circle.diameter
is accessed, as opposed to c.diameter
for some c
that is an instance of Circle
. Make sure you feel comfortable with the difference.
class Length(object):
conversion = {'inches':1.0,
'centimeters':2.54,
'feet':1.0/12,
'meters':2.54/100}
def __set__(self, instance, length):
instance.__value = length[0]
instance.__units = length[1]
def __get__(self, instance, owner):
if instance is None:
return self
return (instance.__value, instance.__units)
def get_in(self, instance, units):
c_factor = self.conversion[units] / self.conversion[instance.__units]
return (c_factor * instance.__value, units)
Now, we can get a hold of the actual Length
instance that lives inside of .diameter
... but only if we access .diameter
hanging off of Circle
(the class itself) and not any instances of that class.
# This works and prints the conversion for `c`.
c = Circle(12, 'inches')
Circle.diameter.get_in(c, 'feet')
# This won't work because you short-circuit as soon as you type `c.diameter`
c.diameter.get_in('feet')
One option to avoid needing to go outside of the instance is to monkey-patch a function that utilizes the __class__
attribute:
class Circle(object):
diameter = Length()
def __init__(self, diameter, units):
self.diameter = (diameter, units)
self.convert = lambda attr, units: (
getattr(self.__class__, attr).get_in(self, units)
)
Now the instance c
can work like this:
>>> c.convert('diameter', 'feet')
(1.0, 'feet')
You could instead define convert
as an instance method (e.g. with the usual self
first argument), or you could do it with decorators, or metaclasses, ... etc.
But at the end of the day, you still need to be very careful. On the surface this looks attractive, but really you are adding a lot of coupling between your objects. It superficially might look like you're separating the concerns about unit conversion away from the object's concerns about "being a Circle" -- but really you're adding layers of complexity that other programmers will have to sort out. And you're marrying your class to this particular Descriptor. If someone determines in a refactoring that diameter conversion is better as a function wholly outside of the Circle
object, they now suddenly have to worry about accurately accounting for all of the moving parts of Length
when they do the refactoring.
At the end of the day, you also have to ask what this buys you. As far as I can tell, in your example it doesn't buy anything except the very minor convenience of being able to induce conversion calculation as part of the so-called "fluent interface" design style ... e.g. side-effects and function calls appear like they are merely attribute accesses.
Personally, I dislike this kind of syntax. I'd much rather use a style like
convert(c.diameter, 'feet')
than
Circle.diameter.convert('feet')
Functions like the first version usually live at a module level, and they can be generalized over the types which they will operate on. They can be extended to handle new types more easily (and you can encapsulate them into their own separate classes if you want inheritance on the functions). Usually, they are also easier to test because vastly less machinery is needed to invoke them, and the testing mock objects can be simpler. In fact, in a dynamically typed language like Python, allowing a function like convert
to work based on duck typing is typically one of the major virtues of the language.
This is not to say one way is definitely better than the other. A good designer could find merits in either approach. A bad designer could make a mess out of either approach. But in general, I find that when these exceptional corners of Python are used to solve unexceptional, regular problems, it often leads to a confused mess.
Upvotes: 2