How to properly hint a class factory with ParamSpec (original) (raw)
I have a base class that defines a class factory.
Then I have user defined classes which can extend the base class but which should use the class factory to create the class instance.
From a code perspective this works well and without issues, however I would like that the class factory is properly type hinted.
Unfortunately I can’t seem to make it work.
from typing import ParamSpec, TypeVar, Type
P = ParamSpec("P")
T = TypeVar("T")
class A:
def __init__(self, p1: int):
pass
@classmethod
def class_factory(cls: Type[T], *args: P.args, **kwargs: P.kwargs) -> T:
return cls(*args, **kwargs)
class B(A):
def __init__(self, p1: int, p2: str):
super().__init__(p1)
B.class_factory() # should be an error but is ok
B.class_factory('asdf', 1) # should be an error but is ok
If I a make a stand alone function this works well:
def create_class(f: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
return f(*args, **kwargs)
create_class(B, 'a', 'b') # <-- correctly identified as error
I tried annotating the class factory like the stand alone function but it doesn’t work.
Can anyone give me any hints?
Thank you for your help!
tmk (Thomas Kehrenberg) June 16, 2023, 6:35am 2
Here you go:
from typing import Generic, ParamSpec, TypeVar, Type
P = ParamSpec("P")
T = TypeVar("T")
class A(Generic[P]):
def __init__(self, p1: int):
pass
@classmethod
def class_factory(cls: Type[T], *args: P.args, **kwargs: P.kwargs) -> T:
return cls(*args, **kwargs)
class B(A[[int, str]]):
def __init__(self, p1: int, p2: str):
super().__init__(p1)
B.class_factory() # E: Too few arguments for "class_factory" of "A" [call-arg]
B.class_factory('asdf', 1) # E: Argument 1 to "class_factory" of "A" has incompatible type "str"; expected "int"
# Argument 2 to "class_factory" of "A" has incompatible type "int"; expected "str" [arg-type]
By the way, for typing questions, you will probably usually get more help at python/typing · Discussions · GitHub instead of here.
Spaceman (Sebastian) June 16, 2023, 6:39am 3
Thank you very much @tmk for your quick reply.
Is there any solution that doesn’t rely on defining A as a generic?
Because if I the user has to define it as a generic he can also just override the class factory which I think is easier especially for new programmers:
class A:
def __init__(self, p1: int):
pass
@classmethod
def class_factory(cls: Type[T], *args: P.args, **kwargs: P.kwargs) -> T:
return cls(*args, **kwargs)
class B(A):
def __init__(self, p1: int, p2: str):
super().__init__(p1)
@classmethod
def class_factory(cls, p1: int, p2: str):
return super().class_factory(p1, p2)
Thank you for your hint. If I don’t get any more help I’ll take a look there (but it doesn’t seem that way so far )
tmk (Thomas Kehrenberg) June 16, 2023, 6:54am 4
Well, the P
has to appear somewhere else in addition to the *args
and **kwargs
annotation because otherwise the type checker doesn’t know what signature P
is supposed to represent.
It seems you want to do something like this:
from typing import Callable, Self
class A:
@classmethod
def factory(cls: Callable[P, Self], *args: P.args, **kwargs: P.kwargs) -> Self: ...
where you can treat the class as a callable. But I don’t think that’s possible.
Perhaps you prefer this?
from typing import Generic, ParamSpec, Self
P = ParamSpec("P")
class FactoryMixin(Generic[P]):
@classmethod
def class_factory(cls, *args: P.args, **kwargs: P.kwargs) -> Self:
return cls(*args, **kwargs)
class A(FactoryMixin[[int]]):
def __init__(self, p1: int):
pass
class B(FactoryMixin[[int, str]]):
def __init__(self, p1: int, p2: str):
pass
B.class_factory()
B.class_factory('asdf', 1)
But this still has the problem that P
is not inferred from the signature of __init__
so you have to specify it manually.
Spaceman (Sebastian) June 16, 2023, 7:46am 5
Yes - repeating the signature is tedious and error prone if it has to be done for 50+ classes on the user side.
The whole idea is to not having to repeat the signature which is not achieved with this solution.
I think the best approach for now is the create_class
stand alone function, even if I think it’s not very elegant.
Do you think it’s worth opening an issue or is this a rather uncommon use case?
Anyway - thank you very much for you help!
tmk (Thomas Kehrenberg) June 16, 2023, 8:03am 6
I’m pretty sure it would need a new PEP to get that feature. You can
pitch it in the Ideas category if you want.
Xukai-Liu (Xukai Liu) August 16, 2024, 4:57am 7
from typing_extensions import ParamSpec, TypeVar, Callable, Generic
P = ParamSpec("P")
T = TypeVar("T")
class B():
def __init__(self, p1: int, p2: str):
super().__init__(p1)
class Builder(Generic[P, T]):
def __init__(self, build_func: Callable[P, T]):
self.f = build_func
def build(self, *args: P.args, **kwargs: P.kwargs) -> T:
return self.f(*args, **kwargs)
Builder_Of_Bs = Builder(B)
Builder_Of_Bs.build()
Hello, I have made a small change to this method to solve my own problem. Hope this may be of some help to you. Instead of modifying the original class, this creates a generic builder class that builds once specific class type. It may not be elegant anyway, but it can safely keep the dirty part and the clean part of our code apart, which is honestly all one can hope for.
anentropic (Anentropic) May 21, 2025, 1:28pm 8
I want to do something similar with alternate constructor for a dataclass
This works:
from dataclasses import dataclass
from typing import ParamSpec, Callable, Self
P = ParamSpec("P")
@dataclass
class B:
a: int
b: str
@classmethod
def build(cls, **kwargs) -> Self:
return cls(**kwargs)
b1 = B(1, "one")
b2 = B.build(a=2, b="two")
And this works:
from dataclasses import dataclass
from typing import ParamSpec, Callable, Self
P = ParamSpec("P")
@dataclass
class B:
a: int
b: str
@classmethod
def build(cls: Callable[P, "B"], *args: P.args, **kwargs: P.kwargs) -> "B":
return cls(*args, **kwargs)
b1 = B(1, "one")
b2 = B.build(a=2, b="two")
# expected error:
b3 = B.build(a="three")
The only thing that doesn’t work is replacing the "B"
with Self
When I do that mypy complains that:
Method cannot have explicit self annotation and Self type
It feels like this is really close to working, we just need a way to implicitly associate a ParamSpec
to the cls
constructor.
This hack also ‘works’, as far as typing the **kwargs
at least:
@classmethod
def build(cls: Callable[P, object], *args: P.args, **kwargs: P.kwargs):
return cls(*args, **kwargs)
…but it messes up the type of cls
within the method and also the return value.