2

I have a innerclass decorator/descriptor that is supposed to pass the outer instance to the inner callable as the first argument:

from functools import partial

class innerclass:

  def __init__(self, cls):
    self.cls = cls

  def __get__(self, obj, obj_type=None):
    if obj is None:
      return self.cls

    return partial(self.cls, obj)

Here's a class named Outer whose .Inner is a class decorated with innerclass:

class Outer:

  def __init__(self):
    self.inner_value = self.Inner('foo')

  @innerclass
  class Inner:

    def __init__(self, outer_instance, value):
      self.outer = outer_instance
      self.value = value

    def __set__(self, outer_instance, value):
      print('Setter invoked')
      self.value = value

I expected that the setter would be invoked when I change the attribute. However, that is not the case:

foo = Outer()
print(type(foo.inner_value))  # <class '__main__.Outer.Inner'>

foo.inner_value = 42
print(type(foo.inner_value))  # <class 'int'>

Why is that and how can I fix it?

InSync
  • 4,851
  • 4
  • 8
  • 30

3 Answers3

1

inner_value is inside of your instance's __dict__. Descriptors apply at the class level. If you assign to foo.bar, then Python looks for a settable descriptor on the type of foo and its parents, not on the dictionary of foo itself. You cannot have descriptors that apply on a per-instance basis; Python just isn't built that way. You have to place the descriptor on a class and then apply it to instances of that class.

Silvio Mayolo
  • 62,821
  • 6
  • 74
  • 116
  • "*You have to place the descriptor on a class and then apply it to instances of that class.*" How can I do that, as Pythonic as possible? – InSync May 19 '23 at 22:14
  • 1
    nitpick: "*inner_value is inside of your class' `__dict__`*": inside of your instance's `__dict__`. – S.B May 21 '23 at 06:41
1

If you change:

self.inner_value = self.Inner('foo')

to:

Outer.inner_value = self.Inner('foo')

You'll see "Setter invoked".


Here is a re-write of your code that might help explaining what's going on:

from functools import partial


class Descriptor:
    def __init__(self, cls):
        self.cls = cls

    def __get__(self, obj, obj_type=None):
        if obj is None:
            return self.cls

        return partial(self.cls, obj)


class Inner:
    def __init__(self, outer_instance, value):
        self.outer = outer_instance
        self.value = value

    def __set__(self, outer_instance, value):
        print("Setter invoked")
        self.value = value


class Outer:
    def __init__(self):
        self.inner_value = self.Inner("foo")

    Inner = Descriptor(Inner)


foo = Outer()
print(type(foo.inner_value))

foo.inner_value = 42
print(type(foo.inner_value))

Let's go through it step by step:

  1. when Outer class is defined, a descriptor named Inner is created in it's namespace which in turn has the Inner class(defined in the outer scope) as the self.cls parameter.

  2. When you execute foo = Outer(), __init__ is called. Because Inner now points to a descriptor, self.Inner triggers the __get__(because the descriptor invoked from an instance which is self).

  3. What does get return from self.Inner("foo")? An instance of the Inner class defined in the global scope. It is also a descriptor with __set__. So self.inner_value points to this descriptor object.

but the problem is this descriptor is inside the foo's namespace. Not the class Outer!:

>>> "inner_value" in foo.__dict__
True

So by doing foo.inner_value = 42 you don't trigger the __set__. You just overwrite it to 42:

>>> foo = Outer()
>>> foo.__dict__
{'inner_value': <__main__.Inner object at 0x7fd716f8c910>}
>>> foo.inner_value = 42
>>> foo.__dict__
{'inner_value': 42}
>>> 

If you want to see the descriptor behavior, the return value of self.Inner("foo") must be in the class:

class Outer:
    def __init__(self):
        Outer.inner_value = self.Inner("foo")  # <--

    Inner = Descriptor(Inner)

Now it will output:

<class '__main__.Inner'>
Setter invoked
<class '__main__.Inner'>
S.B
  • 13,077
  • 10
  • 22
  • 49
  • There are supposed to be more than one `Inner` instance, so using a class attribute is just not the way (or is it?). Is there another solution? – InSync May 21 '23 at 07:15
  • @InSync The bottom line is that the descriptor has to be in the class's namespace. You could dynamically add those to the class in loop or something if you prefer but that's how they work. put them somehow in class' namespace and invoke them from instances. – S.B May 21 '23 at 10:01
1

I came up with a solution using @property. I'm not sure if this is as Pythonic as it should be; if there is a better alternative, I will be open for suggestions.

class Outer:

  def __init__(self):
    # Make it protected/private
    self._inner_value = self.Inner('foo')

  @property
  def inner_value(self):
    return self._inner_value

  @inner_value.setter
  def inner_value(self, value):
    # Invoke _inner_value's property setter
    self._inner_value.value = value

  @innerclass
  class Inner:

    def __init__(self, outer_instance, value):
      self.outer = outer_instance
      self._value = value

    @property
    def value(self):
      return self._value

    @value.setter
    def value(self, new_value):
      print('Setter invoked')
      self._value = new_value

Try it:

foo = Outer()
print(type(foo.inner_value))  # <class '__main__.Outer.Inner'>
                              # Setter invoked
foo.inner_value = 42
print(type(foo.inner_value))  # <class '__main__.Outer.Inner'>
InSync
  • 4,851
  • 4
  • 8
  • 30