问题描述 简述 现有以下类型。我们想要创建在 base 的时候,能够依据 type 键的值自动创建子类实例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 from typing import Literal from pydantic import BaseModel class base (BaseModel ): type : str class sup1 (base ): type : Literal ['sup1' ] sup1: str class sup2 (base ): type : Literal ['sup2' ] sup2: str
问题来源 mirai-api-http
是 QQbot mirai
的 api 插件, 它是使用这种 json 表示消息信息:
1 2 3 4 5 6 7 8 9 { "type" : "FriendMessage" , "sender" : { "id" : 123 , "nickname" : "" , "remark" : "" } , "messageChain" : [ { "type" : "Source" , "id" : 123456 , "time" : 123456 } , { "type" : "AtAll" } , { "type" : "Plain" , "text" : "hello world" } ] }
其中 messageChain 中的 Text 有许多类型,sender 也有许多类型
在往本人的框架 madoka
中加入 pydantic 时,就产生了这种需求 譬如上述 json 实例出来的应该是:
1 2 3 4 5 6 7 context: type: FriendMessage sender: messageChain: - SourceText - AtText - PlainText
实现方法 太长不看版
__init_subclass__
记录所有子类及其 type 键值
创造 type 键值与 cls 的字典
通过 __new__
hack 实例创建的过程,使其返回子类
软件版本
python: 3.9.5
pydantic: 1.8.2
1. 使用 Union 依据 Pydantic 的 文档 Pydantic 会依次尝试 Union 中的类,并使用第一个匹配的
实现代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 from typing import Literal , Union from pydantic import BaseModel class base (BaseModel ): type : str class sup1 (base ): type : Literal ['sup1' ] sup1: str class sup2 (base ): type : Literal ['sup2' ] sup2: str class foo (BaseModel ): __root__: list [Union [sup1, sup2, base]] obj = foo.parse_obj([ {'type' : 'sup1' , 'sup1' : 'this is sup1' }, {'type' : 'sup2' , 'sup2' : 'this is sup2' }, {'type' : 'sup3' , 'sup3' : 'this is sup3' }, ])print (obj)
优劣
优点:
缺点
需要遍历匹配,可能有效率问题
添加新类时,需要手动维护 Union
或者通过 __init_subclass__
动态修改 annotations (此处不展开)
2. 使用 __new__
hack 初始化过程 分析 parse_obj 的初始化过程
cls.parse_obj(obj)
调用 cls(**obj)
python 创建 cls 的实例
调用实例的 __init__
递归处理 实例的键值
解决方法 pydantic 的每一层实例化都逃不开 cls(**obj)
,从 python 官方文档可以找到这样一个函数 __new__
。 它允许我们返回一个其他实例,并调用那个实例的 __init__
。所以我们可以使用这个函数,动态返回子类
实现代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 from __future__ import annotationsfrom typing import Literal , Type , get_args, get_originfrom pydantic import BaseModel class base (BaseModel, extra='forbid' ): type : str class TypeMap : type_key: str = 'type' types: dict [str , Type [base]] = {} extra_name: str = 'Extra' extra: Type [base] @classmethod def add (cls, ins_cls: Type [base] ) -> None : if ins_cls.__name__ == cls.extra_name: cls.extra = ins_cls return field = ins_cls.__fields__.get(cls.type_key) if field is None : return if get_origin(field.type_) is Literal : for name in get_args(field.type_): assert name not in cls.types, "can't have same key value" cls.types[name] = ins_cls def __init_subclass__ (cls, **kwargs ) -> None : cls.TypeMap.add(cls) return super ().__init_subclass__() def __new__ (cls, *args, **kwargs ) -> Type [base]: key = cls.TypeMap.type_key if cls is base: if key in kwargs: new_cls = cls.TypeMap.types.get(kwargs[key], cls.TypeMap.extra) return super ().__new__(new_cls) else : return super ().__new__(cls.TypeMap.extra) else : return super ().__new__(cls)class sup1 (base ): type : Literal ['sup1' ] sup1: str class sup2 (base ): type : Literal ['sup2' ] sup2: str class sup3 (sup2 ): type : Literal ['sup3' ] sup3: str class Extra (base, extra='allow' ): pass class foo (BaseModel ): __root__: list [base] obj = foo.parse_obj([ { 'type' : 'sup1' , 'sup1' : 'this is sup1' }, { 'type' : 'sup2' , 'sup2' : 'this is sup2' }, { 'type' : 'sup3' , 'sup2' : 'this is sup2' , 'sup3' : 'this is sup3' }, { 'type' : 'unknown' , 'extra' : 'this is extra' }, ])print (obj)
这份代码的 pydantic extra 策略是:除了 Extra 外的子类均为 forbid,同时 base 不会有实例的可能。 如果你的 extra 策略和我不同,譬如全局 allow 或 forbid。可以将 Extra 的部分代码删掉,并将 TypeMap.extra 改为 base
其他 在实现这份代码时,我考虑过不同的实现方式。比如使用 metaclass ,但是会与 pydantic 的 meta 冲突。再比如把这份代码抽出成单独的类,从而达到复用,但是容易在类嵌类中冲突,并且代码会过于“动态”,并且考虑到本人框架中使用到这种技术的只有寥寥几个,所以放弃了这种实现方式。