Я хотел бы реализовать шаблон стратегии в pydantic, взяв строку стратегии, импортировав ее как модуль, получив для него модель этого элемента и продолжив проверку модели с помощью динамически импортированной модели.
Например:
data = {
"shape": {
"strategy": "mypkg.Cone",
"radius": 5,
"height": 10
}
"transformations": [
{
"strategy": "mypkg.Translate",
"x": 10,
"y": 10,
"z": 10
},
{
"strategy": "quaternions.Rotate",
"quaternion": [0, 0, 0, 0]
}
]
}
Я бы хотел, чтобы это содержалось в классе модели и было произвольно рекурсивным, чтобы я мог просто использовать Model(**data) и все стратегии были решены, и чтобы модели, загруженные стратегией, могли иметь свои собственные подложки.
Из этих входных данных я ожидаю следующий проверенный экземпляр:
<__main__.Model(
shape=<mypkg.Cone(
radius=5,
height=10
)>,
transformations=[
<mypkg.Translate(x=10, y=10, z=10)>,
<quaternions.Rotate(quaternion=[0,0,0,0])>
]
)>
Самое близкое, что я получил, - это использовать попытку принудительного перестроения модели во время проверки и динамического добавления новых членов в объединения различаемых типов, но это вступает в силу только в СЛЕДУЮЩЕМ цикле проверки модели:
class Model:
shape: Shape
transformations: list[Transformation]
@model_validator(mode = "before")
def adjust_unions(cls, data):
cls.model_fields.update({"shape": import_and_add_to_union(cls, data, "shape")})
cls.model_rebuild(force=True)
return data
import_and_add_to_union берет существующий FieldInfo, импортирует модуль, извлекает
новый член союза и создает новый FieldInfo с annotation для объединения дискриминируемого типа, как с существующими членами союза, так и с вновь импортированным. Это работает правильно, но вступает в силу только в СЛЕДУЮЩЕМ цикле проверки:
try:
Model(**data) # errors
except:
pass
try:
# Now works, but would error out once more for every nested
# level of substrategies any of the strategies may have.
Model(**data)
except:
pass
Кроме того, я бы хотел, чтобы модель Shape была автономным шаблоном стратегии, который при проверке с помощью strategy: cone вместо этого возвращает проверенный экземпляр Cone. Теперь класс Shape требует, чтобы его родительская модель знала, что это стратегическая модель, и родительскому элементу необходимо создать объединение дискриминируемых типов.
Есть ли способ улучшить это?





Кажется, что оптимального решения можно достичь гораздо проще, переопределив __new__ следующим образом:
class Strategy(BaseModel):
strategy: str
def __new__(cls, *args, **kwargs):
qualifiers = kwargs.get("strategy").split(".")
module = ".".join(qualifiers[:-1])
attr = qualifiers[-1]
module_ref = importlib.import_module(module)
child_cls = getattr(module_ref, attr)
return super().__new__(child_cls)
любой класс, наследуемый от Strategy, будет загружен в соответствии со значением его атрибута strategy. В примере модуля под названием my.pkg:
class Cone(Strategy):
radius: float
height: float
а затем откуда угодно:
>>> Strategy(strategy = "my.pkg.Cone", radius=3, height=5)
Cone(strategy='my.pkg.Cone', radius=3.0, height=5.0)
Хотя на первый взгляд это выглядит элегантным решением, я бы посоветовал не привязывать строку к фактическому имени класса и местоположению в пакете. Это усложнит обслуживание в будущем. Например, когда классы переименовываются или перемещаются, старые файлы не разрешаются, что приводит к ошибке.
Вместо этого я бы рекомендовал определить независимый тег. Это позволяет вам работать со стандартным шаблоном Pydantic: дискриминируемым объединением.
Вот минимальный пример:
from pydantic import BaseModel, Field
from typing import Annotated, Literal, Union
class Strategy(BaseModel):
pass
# The subclasses can live in the same file or
# in different files, as long as they inherit from Strategy
class Cone(Strategy):
strategy: Literal["my-cone"] = "my-cone"
radius: float
height: float
class Translate(Strategy):
strategy: Literal["my-translate"] = "my-translate"
x: float
y: float
z: float
class Rotate(Strategy):
strategy: Literal["my-rotate"] = "my-rotate"
quaternion: tuple[float, float, float, float]
def get_all_subclasses(cls):
"""Get subclasses"""
for subclass in cls.__subclasses__():
yield from get_all_subclasses(subclass)
yield subclass
sub_classes = tuple(get_all_subclasses(Strategy))
AnyStrategy = Annotated[Union[sub_classes], Field(discriminator = "strategy")]
class Model(BaseModel):
shape: AnyStrategy
transformations: list[AnyStrategy]
data = {
"shape": {
"strategy": "my-cone",
"radius": 5,
"height": 10
},
"transformations": [
{
"strategy": "my-translate",
"x": 10,
"y": 10,
"z": 10
},
{
"strategy": "my-rotate",
"quaternion": [0, 0, 0, 0]
}
]
}
Model(**data)
Что возвращает:
Model(shape=Cone(strategy='my-cone', radius=5.0, height=10.0), transformations=[Translate(strategy='my-translate', x=10.0, y=10.0, z=10.0), Rotate(strategy='my-rotate', quaternion=(0.0, 0.0, 0.0, 0.0))])
Хотя это и облегчило сохранение тега strategy на ваших подклассах, я думаю, что это приводит к более стабильному и простому в обслуживании коду в долгосрочной перспективе.
Если вас действительно волнует решение с расположением пакетов и именами классов, вы можете автоматически генерировать их, используя вызываемые дискриминаторы.
Надеюсь, это поможет!
Я понимаю. Однако важно понимать, что это «подрывает» предполагаемое использование статически определенной модели в Pydantic. Модель представляет собой фиксированную схему после ее определения. Ваш случай, вероятно, скорее охватывает динамически определенную модель, например. create_model(), где типы можно определить во время выполнения.
Да, требования, которые я изложил в своем вопросе, являются жесткими. Дискриминируемый союз неизвестен до начала проверки, поэтому я никак не могу определить
Union[sub_classes]илиget_all_subclasses, поскольку они еще не импортированы и являются динамическими неизвестными, поэтому мне нужны имя модуля и атрибута. Это просто природа проблемы. Я знал о дискриминационных союзах и даже внедрил решение, которое автоматически создает их на основе содержания. Я также опубликую это решение. Тем не менее, спасибо за хорошо построенный ответ!