创建一个类型提示,该提示使用派生类提供的内部类



我正在构建一个处理序列化数据的API,我更愿意添加对尽可能多的静态分析的支持。我受到 Django 在类上声明元数据的Meta模式的启发,并使用类似于pydantic的内部库来检查序列化的类型注释。

我希望 API 的工作方式如下:

class Base:
Data: type
def __init__(self):
self.data = self.Data()

class Derived(Base):
class Data:
# Actually, more like a pydantic model.
# Used for serialization.
attr: str = None

obj = Derived()
type(obj.data)  # Derived.Data
obj.data.attr = 'content'

这很好用并且可读,但是它似乎根本不支持静态分析。如何在Base中注释self.data,以便我在obj上获得正确的类型信息?

reveal_type(obj.data)  # Derived.Data
reveal_type(obj.data.attr)  # str
obj.data.attr = 7  # Should be static error
obj.data.other = 7  # Should be static error

我可能会写self.data: typing.Self.Data但这显然不起作用。


我能够通过typing.Generic和转发引用获得接近的东西:

import typing
T = typing.TypeVar('T')
class Base(typing.Generic[T]):
Data: type[T]
def __init__(self):
self.data: T = self.Data()
class Derived(Base['Derived.Data']):
class Data:
attr: str = None

但它不是 DRY,它不会强制注释和运行时类型实际匹配。例如:

class Derived(Base[SomeOtherType]):
class Data:  # Should be static error
attr: str = None
type(obj.data)  # Derived.Data
reveal_type(obj.data)  # SomeOtherType

我也可以要求派生类为data提供注释,但这会遇到与typing.Generic类似的问题。

class Derived(Base):
data: SomeOtherClass  # should be 'Data'
class Data:  # should be a static error
attr: str = None

为了尝试解决此问题,我尝试在__init_subclass__中编写一些验证逻辑,以确保T匹配cls.data; 但是这很脆弱,并非在所有情况下都有效。它还禁止创建任何定义Data的抽象派生类。

这实际上并不平凡,因为您遇到了想要动态创建类型的经典问题,同时让静态类型检查器理解它们。一个明显的矛盾。

<小时 />

快速皮丹特语题外话

既然你提到了Pydantic,我就接手了。他们解决它的方法,大大简化,是从来没有实际实例化内部Config类。相反,__config__属性是在类上设置的,只要你子类BaseModel并且此属性本身是一个(意味着type的实例)。

__config__引用的该类继承自BaseConfig,并由ModelMetaclass构造函数动态创建。在此过程中,它继承了模型基类设置的所有属性,并用您在内部Config中设置的任何属性覆盖它们。

您可以在此示例中看到结果:

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

顺便说一下,这就是为什么你不应该在代码中直接引用内部Config。它只会具有您在该类上显式设置的属性,并且不会继承任何内容,甚至不会继承BaseConfig的默认值。访问完整模型配置的记录方法是通过__config__

这也是为什么没有模型实例配置这样的东西。更改__config__的属性,您将为整个类/模型更改它:

from pydantic import BaseModel
foo = BaseModel()
bar = BaseModel()
assert not foo.__config__.frozen
bar.__config__.frozen = True
assert foo.__config__.frozen
<小时 />

可能的解决方案

此方法的一个重要约束是,只有当您拥有所有这些动态创建的类都可以从中继承的固定类型时,它才真正有意义。在 Pydantic 的情况下,它是BaseConfig的,并且相应地注释了__config__属性,即使用type[BaseConfig],这允许静态类型检查器推断该__config__类的接口。

当然,你可以走相反的道路,允许为类Data定义任何内部类,但这可能违背了设计的目的。不过它可以正常工作,您可以通过元类挂钩到类创建中,以强制设置Data和类。您甚至可以强制设置该内部类上的特定属性,但此时您不妨为此提供一个公共基类。

如果你想复制Pydantic方法,我可以给你一个非常粗略的例子,说明如何做到这一点,基本思想无耻地从Pydantic代码中窃取(或受到启发)。

您可以设置一个BaseData类,并完全定义其注释和类型推断的属性。然后,设置自定义元类。在其__new__方法中,执行继承循环以动态生成新的BaseData子类,并将结果分配给新外部类的__data__属性:

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...

有了这个,您现在可以定义Base类,使用type[BaseData]注释其命名空间中的__data__,并将BaseData分配给其Data属性。所有派生类上的内部Data类现在可以只定义那些与其父类Data不同的属性。要证明这有效,请尝试以下操作:

...  # 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

静态类型检查器当然也知道从__data__属性中得到什么,IDE应该给出适当的自动建议。如果在底部添加reveal_type(obj2.__data__.foo)reveal_type(obj2.__data__.bar)并对代码运行mypy,它将输出显示的类型分别strint

<小时 />

注意事项

这种方法的一个重要缺点是继承被抽象出来,静态类型检查器将内部Data类视为与BaseData无关的自己的类,这是有道理的,因为它就是这样;它只是从object继承。

因此,您将不会得到有关 IDE 可以覆盖Data的属性的任何建议。这与Pydantic的交易相同,这也是他们为mypy和PyCharm推出自己的自定义插件的原因之一。后者允许 PyCharm 在你在任何派生类上编写内部Data类时向你建议BaseConfig属性。

我知道我已经提供了一个答案,但是在反复之后,我想到了另一种可能的解决方案,涉及与我之前提出的完全不同的设计。我认为这提高了可读性,如果我将其作为第二个答案发布。


没有内部类;只有一个类型参数

有关如何访问子类化期间提供的 type 参数的详细信息,请参阅此处。

from typing import Generic, TypeVar, get_args, get_origin

D = TypeVar("D", bound="BaseData")

class BaseData:
foo: str = "abc"
bar: int = 1

class Base(Generic[D]):
__data__: type[D]
@classmethod
def __init_subclass__(cls, **kwargs: object) -> None:
super().__init_subclass__(**kwargs)
for base in cls.__orig_bases__:  # type: ignore[attr-defined]
origin = get_origin(base)
if origin is None or not issubclass(origin, Base):
continue
type_arg = get_args(base)[0]
# Do not set the attribute for GENERIC subclasses!
if not isinstance(type_arg, TypeVar):
cls.__data__ = type_arg
return

用法:

class Derived1Data(BaseData):
foo = "xyz"

class Derived1(Base[Derived1Data]):
pass

class Derived2Data(Derived1Data):
bar = 42
baz = True

class Derived2(Base[Derived2Data]):
pass

if __name__ == "__main__":
obj1 = Derived1()
obj2 = Derived2()
assert "xyz" == obj1.__data__.foo == obj2.__data__.foo
assert 42 == obj2.__data__.bar
assert not hasattr(obj1.__data__, "baz")
assert obj2.__data__.baz

mypy添加reveal_type(obj1.__data__)reveal_type(obj2.__data__)将分别显示type[Derived1Data]type[Derived2Data]

缺点是显而易见的:它不是你心目中的"内部类"设计。

好处是完全类型安全,同时需要最少的代码。用户只需要提供自己的BaseData子类作为类型参数,当子类化时Base


添加实例(可选)

如果您希望__data__成为指定BaseData子类的实例属性和实际实例,这也很容易实现。这是一个粗略但有效的例子:

from typing import Generic, TypeVar, get_args, get_origin

D = TypeVar("D", bound="BaseData")

class BaseData:
foo: str = "abc"
bar: int = 1
def __init__(self, **kwargs: object) -> None:
self.__dict__.update(kwargs)

class Base(Generic[D]):
__data_cls__: type[D]
__data__: D
@classmethod
def __init_subclass__(cls, **kwargs: object) -> None:
super().__init_subclass__(**kwargs)
for base in cls.__orig_bases__:  # type: ignore[attr-defined]
origin = get_origin(base)
if origin is None or not issubclass(origin, Base):
continue
type_arg = get_args(base)[0]
# Do not set the attribute for GENERIC subclasses!
if not isinstance(type_arg, TypeVar):
cls.__data_cls__ = type_arg
return
def __init__(self, **data_kwargs: object) -> None:
self.__data__ = self.__data_cls__(**data_kwargs)

用法:

class DerivedData(BaseData):
foo = "xyz"
baz = True

class Derived(Base[DerivedData]):
pass

if __name__ == "__main__":
obj = Derived(baz=False)
print(obj.__data__.foo)  # xyz
print(obj.__data__.bar)  # 1
print(obj.__data__.baz)  # False

同样,静态类型检查器将知道__data__属于DerivedData类型。

不过,我想在这一点上,您不妨让用户在初始化Derived期间提供他自己的BaseData子类实例。无论如何,也许这是一个更干净,更直观的设计。

我认为您最初的想法只有在您为静态类型检查器推出自己的插件时才有效。

它不是完全干燥的,但鉴于 @daniil-fajnberg 的建议,我认为这可能是可取的。显式比隐式更好,对吧?

这个想法是要求派生类为数据指定类型注释;类型检查器会很高兴,因为派生类都使用正确的类型进行注释,而基类只需要检查该单个注释即可确定运行时类型。

from typing import ClassVar, TypeVar, get_type_hints

class Base:
__data_cls__: ClassVar[type]
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
hints = get_type_hints(cls)
if 'data' in hints:
if isinstance(hints['data'], TypeVar):
raise TypeError('Cannot infer __data_cls__ from TypeVar.')
cls.__data_cls__ = hints['data']
def __init__(self):
self.data = self.__data_cls__()

用法如下所示。请注意,数据类型的名称和数据属性不再耦合。

class Derived1(Base):
class TheDataType:
foo: str = ''
bar: int = 77
data: TheDataType

print('Derived1:')
obj1 = Derived1()
reveal_type(obj1.data)  # Derived1.TheDataType
reveal_type(obj1.data.foo)  # str
reveal_type(obj1.data.bar)  # int

这种解耦意味着您不需要使用内部类型

class Derived2(Base):
data: Derived1.TheDataType

print('Derived3:')
obj2 = Derived2()
reveal_type(obj2.data)  # Derived1.TheDataType
reveal_type(obj2.data.foo)  # str
reveal_type(obj2.data.bar)  # int

我认为在此解决方案中不可能支持泛型子类。在某些情况下,可以调整 https://stackoverflow.com/a/74788026/4672189 中的代码以获取运行时类型。