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.