Instances of Power in Python

We explored the concept of callable instances using __call__() in a previous example (recent post), observing that the search for “dunder” methods prioritizes the instance’s class over the instance itself. This behavior extends to __getattribute__(), __setattribute__(), and descriptors, which function differently when defined in a class versus an instance. We introduced a technique to overcome this by dynamically creating a new class with the desired dunder methods and reassigning the instance’s __class__ attribute.

Building upon this, we can generalize the technique using a decorator to create “power-instances.” This decorator enhances classes, enabling instance-specific modifications without affecting the entire class. It generates a PowerInstance class inheriting from the original, equipped with methods to add instance methods, properties, callable behavior, interception, and even modify inheritance.

Let’s illustrate with an implementation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
from types import MethodType

# Decorator to empower class instances with individual enhancements
def power_instance(cls):
    class PowerInstance(cls):
        # On instantiation, create a unique class for each instance
        def __new__(_cls, *args, **kwargs):
            class PerInstanceClass(_cls):
                pass
            return super().__new__(PerInstanceClass)

        # Helper to add attributes to the instance's unique class
        def _add_to_instance(self, item, item_name):
            setattr(type(self), item_name, item)

        # Public methods to add methods, properties, callable behavior, etc.
        def add_instance_method(self, fn, fn_name: str):
            self._add_to_instance(fn, fn_name)

        def add_instance_property(self, prop, prop_name: str):
            self._add_to_instance(prop, prop_name)

        def do_callable(self, call_fn):
            type(self).__call__ = call_fn

        def intercept_getattribute(self, call_fn):
            type(self).__getattribute__ = call_fn

        # Enable instance-specific inheritance
        def do_instance_inherit_from(self, cls):
            class PerInstanceNewChild(type(self), cls):
                pass
            self.__class__ = PerInstanceNewChild

    # Preserve original class attributes for clarity
    for attr in '__doc__', '__name__', '__qualname__', '__module__':
        setattr(PowerInstance, attr, getattr(cls, attr))
    return PowerInstance

Here’s an example demonstrating its usage. Observe how we enrich the p1 instance without impacting other instances of the same class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@power_instance
class Person:
    def __init__(self, name: str):
        self.name = name

    def say_hi(self, who: str) -> str:
        return f"{self.name} says Bonjour to {who}"

print(f"Person.__name__: {Person.__name__}")
p1 = Person("Iyan")
print(p1.say_hi("Antoine"))

# Add an instance-specific method
def say_bye(self, who: str):
    return f"{self.name} says Bye to {who}"
p1.add_instance_method(say_bye, "say_bye")
print(p1.say_bye("Marc"))

# Add an instance-specific property
p1.add_instance_property(property(lambda self: self.name.upper()), "upper_name")
print(p1.upper_name)

# Make the instance callable
p1.do_callable(lambda self: f"[[{self.name.upper()}]]")
print(p1())

# Output:
# Person.__name__: Person
# Iyan says Bonjour to Antoine
# Iyan says Bye to Marc
# IYAN
# [[IYAN]]

print("------------------")

# Verify instance-specific changes
p2 = Person("Francois")
print(p2.say_hi("Antoine"))

try:
    print(p2.say_bye("Marc"))
except Exception as ex:
    print(f"Exception! {ex}")

try:
    print(p2())
except Exception as ex:
    print(f"Exception! {ex}")

# Output:
# Francois says Bonjour to Antoine
# Exception! 'PerInstanceClass' object has no attribute 'say_bye'
# Exception! 'PerInstanceClass' object is not callable

Let’s extend our instance to inherit from another class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class Animal:
    def growl(self, who: str) -> str:
        return f"{self.name} is growling to {who}"

p1.do_instance_inherit_from(Animal)

print(p1.growl("aa"))
print(p1.say_hi("bb"))
print(p1.say_bye("cc"))
print(p1())
print(f"mro: {type(p1).__mro__}")
print(f"bases: {type(p1).__bases__}")

# Output:
# Iyan is growling to aa
# Iyan says Bonjour to bb
# Iyan says Bye to cc
# [[IYAN]]
# mro: (<class '.PowerInstance.do_instance_inherit_from.<locals>.PerInstanceNewChild'>, <class '.PowerInstance.__new__.<locals>.PerInstanceClass'>, <class '__main__.Person'>, <class 'object'>)
# bases: (<class '.PowerInstance.__new__.<locals>.PerInstanceClass'>, <class '__main__.Animal'>)

Now, let’s explore attribute interception using __getattribute__():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# Intercept attribute access in the instance
print("- Interception:")

# Interceptor without using "self"
def interceptor(instance, attr_name):
    attr = object.__getattribute__(instance, attr_name)
    if not callable(attr):
        return attr

    def wrapper(*args, **kwargs):
        print(f"before invoking {attr_name}")
        res = attr(*args, **kwargs)
        print(f"after invoking {attr_name}")
        return res
    return wrapper

p1.intercept_getattribute(interceptor)
print(p1.say_hi("Antoine"))

p3 = Person("Francois")

# Interceptor using "self"
def interceptor2(instance, attr_name):
    attr = object.__getattribute__(instance, attr_name)
    if not callable(attr):
        return attr

    def wrapper(self, *args, **kwargs):
        print(f"before invoking {attr_name} in instance: {type(self)}")
        res = attr(*args, **kwargs)
        print(f"after invoking {attr_name} in instance: {type(self)}")
        return res

    return MethodType(wrapper, instance)

p3.intercept_getattribute(interceptor2)
print(p3.say_hi("Antoine"))

# Output:
# - Interception:
# before invoking say_hi
# after invoking say_hi
# Iyan says Bonjour to Antoine
# before invoking say_hi in instance: <class '.PowerInstance.__new__.<locals>.PerInstanceClass'>
# after invoking say_hi in instance: <class '.PowerInstance.__new__.<locals>.PerInstanceClass'>
# Francois says Bonjour to Antoine

The ability to return a different object than the expected instance from a constructor isn’t unique to Python. JavaScript also allows constructors to return different objects. In Python, construction and initialization occur in two steps: __new__() and __init__(), invoked by the metaclass’s __call__() method. You can find a simplified implementation of type.__call__() here: this discussion

1
2
3
4
5
6
# Simplified type.__call__
def __call__(cls, *args, **kwargs):
    rv = cls.__new__(cls, *args, **kwargs)
    if isinstance(rv, cls):
        rv.__init__(*args, **kwargs)
    return rv

It’s worth noting that returning different class instances from a constructor, especially different classes each time, can be considered a departure from traditional object-oriented principles. Normally, you’d anticipate instances of the same class from a constructor. This expectation doesn’t apply to factory functions. Further discussion on this perspective can be found here: in stackoverflow.

Licensed under CC BY-NC-SA 4.0
Last updated on Aug 06, 2022 06:48 +0100