Delegate class construction leads to recursion error

43 views Asked by At

I'm building a framework and want to allow users to easily influence the construction of an object (e.g. singletons)

However no matter what I've tried I always get recursion. I wonder if there's a way to achieve this without altering create_instance. I'm sure I can probably achieve this by passing all the necessary stuff from the metaclass to the function, but the key is keeping it extremely simple for end users.

def create_instance(cls, *args, **kwargs):
    print("CUSTOM LOGIC TO CREATE INSTANCE")
    return cls(*args, **kwargs)


class PersonMetaclass(type):
    def __call__(cls, *args, **kwargs):
        # Delegate creation elsewhere
        return create_instance(cls, *args, **kwargs)


class Person(metaclass=PersonMetaclass):
    def __init__(self, *args, **kwargs):
        print("Person instance created")


person = Person()

Output:

CUSTOM LOGIC TO CREATE INSTANCE
CUSTOM LOGIC TO CREATE INSTANCE
CUSTOM LOGIC TO CREATE INSTANCE
CUSTOM LOGIC TO CREATE INSTANCE
..
E   RecursionError: maximum recursion depth exceeded while calling a Python object
!!! Recursion detected (same locals & position)
2

There are 2 answers

2
jsbueno On

The problem here is that when creating a class' instance, what Python does is exactly call the metaclass' __call__ method. type's call is responsible for calling the class __new__ and __init__ to create the new instance.

And in your code, the metaclass __call__ calls a function that will try to build an instance by simply calling the class again: that will call the same __call__ method, and there is your recursion.

I am usually very verbose against using metaclasses for creating singletons - it usually just a matter of creating an instance of the desired class to act as singleton, and expose that in the documentation. Or just modifying the class' __new__ method - no metaclasses needed. (But changing the __new__ alone might cause the __init__ method, if any, to be called more than once).

Anyway, a simple thing there, if you don't want to change anything in create_instance, is to have some control in the metaclass' __call__ method to detect when it is been re-entered, and run super().__call__(), instead of calling create_instance. If you use a ContextVar for that, it will work across threads and asynchronous code. (Multiprocessed code, would, naturally have one instance per process)

from contextvars import copy_context, ContextVar

CREATING_SINGLETON = ContextVar("CREATING_SINGLETON", default=False)

def create_instance(cls, *args, **kwargs):
    print("CUSTOM LOGIC TO CREATE INSTANCE")
    return cls(*args, **kwargs)


class PersonMetaclass(type):
    def __call__(cls, *args, **kwargs):
        # Delegate creation elsewhere
        if CREATING_SINGLETON.get():
            return super().__call__(*args, **kwargs)
        ctx = copy_context()
        # Do not look at me: I didn't create
        # the  contextvars API!
        # this is the way to set a variable in the copied cotnext
        ctx.run(lambda: CREATING_SINGLETON.set(True))
        instance = ctx.run(create_instance, cls, *args, **kwargs)
        del ctx
        return instance


class Person(metaclass=PersonMetaclass):
    def __init__(self, *args, **kwargs):
        print("Person instance created")


person = Person()

0
Pithikos On

With some inspiration from @jsbueno I figured a similar but more generic solution.

The important bit is essentially keeping track in a flag or global of when the delegated function has been called.

def create_instance(cls, *args, **kwargs):
    print("CUSTOM LOGIC TO CREATE INSTANCE")
    return cls(*args, **kwargs)


class PersonMetaclass(type):
    def __call__(cls, *args, **kwargs):
        
        # Delegate instance creation
        if getattr(create_instance, "_creating", False):
            return cls.__new__(cls)  # Break recursion
        create_instance._creating = True
        instance = create_instance(cls, *args, **kwargs)
        create_instance._creating = False
        
        # Initialize instance
        instance.__init__(*args, **kwargs)
        return instance


class Person(metaclass=PersonMetaclass):
    def __init__(self, *args, **kwargs):
        print("Person instance created")


person = Person()

For a simple single-threaded solution this seems fine. For asyncio/threading this can be alterted similar to @jsbueno