This is actually non-trivial because you run into the classic problem of wanting to dynamically create types, while simultaneously having static type checkers understand them. An obvious contradiction in terms.
Quick Pydantic digression
Since you mentioned Pydantic, I'll pick up on it. The way they solve it, greatly simplified, is by never actually instantiating the inner Config
class. Instead, the __config__
attribute is set on your class, whenever you subclass BaseModel
and this attribute holds itself a class (meaning an instance of type
).
That class referenced by __config__
inherits from BaseConfig
and is dynamically created by the ModelMetaclass
constructor. In the process it inherits all the attributes set by the model's base classes and overrides them with whatever you set in the inner Config
.
You can see the consequences in this example:
from pydantic import BaseConfig, BaseModel
class Model(BaseModel):
class Config:
frozen = True
a = BaseModel()
b = Model()
a_conf = a.__config__
b_conf = b.__config__
assert isinstance(a_conf, type) and issubclass(a_conf, BaseConfig)
assert isinstance(b_conf, type) and issubclass(b_conf, BaseConfig)
assert not a_conf.frozen
assert b_conf.frozen
By the way, this is why you should not refer to the inner Config
directly in your code. It will only have the attributes you set on that one class explicitly and nothing inherited, not even the defaults from BaseConfig
. The documented way to access the full model config is via __config__
.
This is also why there is no such thing as model instance config. Change an attribute of __config__
and you'll change it for the entire class/model:
from pydantic import BaseModel
foo = BaseModel()
bar = BaseModel()
assert not foo.__config__.frozen
bar.__config__.frozen = True
assert foo.__config__.frozen
Possible solutions
An important constraint of this approach is that it only really makes sense, when you have some fixed type that all these dynamically created classes can inherit from. In the case of Pydantic it is BaseConfig
and the __config__
attribute is annotated accordingly, namely with type[BaseConfig]
, which allows a static type checker to infer the interface of that __config__
class.
You could of course go the opposite way and allow literally any inner class to be defined for Data
on your classes, but this probably defeats the purpose of your design. It would work fine though and you could hook into class creation via the meta class to enforce that Data
is set and a class. You could even enforce that specific attributes on that inner class are set, but at that point you might as well have a common base class for that.
If you wanted to replicate the Pydantic approach, I can give you a very crude example of how this can be accomplished, with the basic ideas shamelessly stolen from (or inspired by) the Pydantic code.
You can set up a BaseData
class and fully define its attributes for the annotations and type inferences down the line. Then you set up your custom meta class. In its __new__
method you perform the inheritance loop to dynamically build the new BaseData
subclass and assign the result to the __data__
attribute of the new outer class:
from __future__ import annotations
from typing import ClassVar, cast
class BaseData:
foo: str = "abc"
bar: int = 1
class CustomMeta(type):
def __new__(
mcs,
name: str,
bases: tuple[type],
namespace: dict[str, object],
**kwargs: object,
) -> CustomMeta:
data = BaseData
for base in reversed(bases):
if issubclass(base, Base):
data = inherit_data(base.__data__, data)
own_data = cast(type[BaseData], namespace.get('Data'))
data = inherit_data(own_data, data)
namespace["__data__"] = data
cls = super().__new__(mcs, name, bases, namespace, **kwargs)
return cls
def inherit_data(
own_data: type[BaseData] | None,
parent_data: type[BaseData],
) -> type[BaseData]:
if own_data is None:
base_classes: tuple[type[BaseData], ...] = (parent_data,)
elif own_data == parent_data:
base_classes = (own_data,)
else:
base_classes = own_data, parent_data
return type('Data', base_classes, {})
... # more code below...
With this you can now define your Base
class, annotate __data__
in its namespace with type[BaseData]
, and assign BaseData
to its Data
attribute. The inner Data
classes on all derived classes can now define just those attributes that are different from their parents' Data
. To demonstrate that this works, try this:
... # Code from above
class Base(metaclass=CustomMeta):
__data__: ClassVar[type[BaseData]]
Data = BaseData
class Derived1(Base):
class Data:
foo = "xyz"
class Derived2(Derived1):
class Data:
bar = 42
if __name__ == "__main__":
obj0 = Base()
obj1 = Derived1()
obj2 = Derived2()
print(obj0.__data__.foo, obj0.__data__.bar) # abc 1
print(obj1.__data__.foo, obj1.__data__.bar) # xyz 1
print(obj2.__data__.foo, obj2.__data__.bar) # xyz 42
Static type checkers will of course also know what to expect from the __data__
attribute and IDEs should give proper auto-suggestions for it. If you add reveal_type(obj2.__data__.foo)
and reveal_type(obj2.__data__.bar)
at the bottom and run mypy
over the code, it will output that the revealed types are str
and int
respectively.
Caveat
An important drawback of this approach is that the inheritance is abstracted away in such a way that the inner Data
class is treated as its own class unrelated to BaseData
in any way by a static type checker, which makes sense because that is what it is; it just inherits from object
.
Thus, you will not get any suggestions about the attributes you can override on Data
by your IDE. This is the same deal with Pydantic, which is one of the reasons they roll their own custom plugins for mypy
and PyCharm for example. The latter allows PyCharm to suggest you the BaseConfig
attributes, when you are writing the inner Data
class on any derived class.