pythonpython-typingfactory

Class factory builder with correct signature


I'm trying to implement a factory design pattern (python 3.12+) and I'm having some trouble keeping my factory signature correct. Maybe what I'm trying to do is not conventional and I should look for an other design pattern.

Let's take this code example :

from typing import ParamSpec, TypeVar

P = ParamSpec("P")
T = TypeVar("T")

class Base:
    @classmethod
    def factory(cls, *args:P.args, **kwargs:P.kwargs):
        def f(parent):
            return cls(parent, *args, **kwargs)
        return f

class CustomA(Base):
    def __init__(self, parent:str, arg1:int, arg2:int):
        print(f"Instantiate CustomA with arg {parent}, {arg1}, {arg2}")

#Ideally I would like this method signature to match the CustomA constructor signature 
#without the parent argument
test = CustomA.factory(1, 2)
print(test) #Does correctly return a method
a = test("parent")
print(a) # That when is called return the object correctly instantiate

In my use case I would like to partially prepare a method call to instantiate an object while missing a parameter for that. Later I would complete that partial call to actually instantiate the object. In order to do so I implemented a factory builder method in the parent class that would allow me to easily implement that feature in all my child class. The code above is working fine, but I lose the signature and therefore IDE IntelliSense for the subclass constructor.

As you can see I tried to use ParamSpec and TypeVar but I don't really understand how they work. Maybe a different approach using the new method, or a decorator would be more suitable ? I didn't had success so far with those other approach.


Solution

  • You need two more steps to make it work: first, bind ParamSpec to constructor arguments, and second, extract the first arg from the signature. The former can be done by reinterpreting current class (cls) as a Callable - remember that type[Something] is very similar to type of its constructor. The latter can be done using Concatenate (3.10+, import from typing_extensions on older versions). So the final attempt might look like this:

    from typing import Callable, Concatenate, ParamSpec, TypeVar
    
    P = ParamSpec("P")
    T = TypeVar("T")
    R = TypeVar("R")
    
    class Base:
        @classmethod
        def factory(cls: Callable[Concatenate[T, P], R], *args: P.args, **kwargs: P.kwargs) -> Callable[[T], R]:
            def f(parent: T) -> R:
                return cls(parent, *args, **kwargs)
            return f
    
    class CustomA(Base):
        def __init__(self, parent: str, arg1: int, arg2: int) -> None:
            print(f"Instantiate CustomA with arg {parent}, {arg1}, {arg2}")
    
    #Ideally I would like this method signature to match the CustomA constructor signature 
    #without the parent argument
    test = CustomA.factory(1, 2)
    print(test) #Does correctly return a method
    a = test("parent")
    print(a) # That when is called return the object correctly instantiate
    

    and it passes mypy cleanly. To satisfy pyright, you will also need to define __init__ on Base with at least one argument (like def __init__(self, parent: str) -> None: pass), or it will consider Callable with at least one arg (required by Concatenate) incompatible with cls.