Rick
Rick

Reputation: 45231

Bind the instance argument of a descriptor method to the calling object instance

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

Answers (2)

Rick
Rick

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

ely
ely

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

Related Questions