2025, Sep 24 01:17
XOR во входах Pydantic: ID или объект, валидация и ленивая загрузка
Паттерн для Pydantic: XOR между ID и объектом, валидатор, алиасы и PrivateAttr, ленивая загрузка ресурса и избавление от Optional после валидации и типизация.
Построить модель, которая принимает ровно один из двух входов и позже вычисляет недостающую часть, кажется простым — пока не вмешаются типизация и правила валидации. Цель понятна: принимать либо ID, либо целый объект, гарантировать, что одновременно передаётся только один вариант, и оставить дорогую загрузку ленивой, чтобы I/O выполнялся лишь по необходимости.
Постановка задачи
Изначальный подход опирается на приватные атрибуты и вычисляемые свойства. На первый взгляд всё работает, но статическая типизация ругается, а правило XOR между двумя входами неудобно формализовать.
from pydantic import BaseModel, model_validator
from typing import Optional
class Asset:
    ident: int
def load_asset_by_id(ident: int) -> Asset:
    ...
class AssetEnvelope(BaseModel):
    _asset_id: Optional[int] = None
    _asset: Optional[Asset] = None
    
    @model_validator(mode="before")
    @classmethod
    def ensure_reference(cls, payload):
        if payload.get("_asset_id") is None and payload.get("_asset") is None:
            raise ValueError("Define either _asset_id or _asset")
    @property
    def asset_id(self) -> int:
        if self._asset_id is None:
            self._asset_id = self.asset.ident
        return self._asset_id  # проверка типов: значение всё ещё может быть None
    
    @asset_id.setter
    def asset_id(self, ident: int):
        self._asset_id = ident
    
    @property
    def asset(self) -> Asset:
        if self._asset is None:
            self._asset = load_asset_by_id(self.asset_id)
        return self._asset  # проверка типов: значение всё ещё может быть None
        
    @asset.setter
    def asset(self, obj: Asset):
        self._asset = obj
AssetEnvelope(_asset_id=5)
Что идёт не так и почему
Проблемы здесь переплетены. Во‑первых, ограничение XOR между двумя входами сложно поддерживать, когда модель хранит их в приватных атрибутах. Нужно принимать либо одно, либо другое — но не оба сразу и не ни одного. Во‑вторых, ленивые геттеры логичны, но проверяющие типы справедливо указывают: возвращаемые значения могут оставаться None, потому что для системы типов состояние Optional неочевидно. В итоге свойства работают на рантайме, но статический анализ всё равно помечает их как потенциальный None. И вдобавок, дорогой I/O для получения объекта по его ID не должен выполняться, пока к объекту реально не обратятся.
Решение: жёсткий XOR и ленивая загрузка
Идея проста. Используйте настоящий поле‑идентификатор и алиас для входного объекта. Держите приватный кэш для уже разрешённого объекта — так загрузка останется ленивой. Проверяйте правило XOR валидатором модели. Если пришёл объект, проставьте идентификатор из него, чтобы дальше работать с ним как с гарантированно заданным без шума Optional.
from __future__ import annotations
from pydantic import BaseModel, Field, PrivateAttr, model_validator
from typing import Optional
class Resource:
    key: int
def resolve_resource(key: int) -> Resource:
    ...
class ResourceCarrier(BaseModel):
    ref_id: Optional[int] = None
    _res_in: Optional[Resource] = Field(default=None, alias="resource")
    _res_cache: Optional[Resource] = PrivateAttr(None)
    @model_validator(mode="after")
    def _xor_and_populate(self):
        if (self.ref_id is None) == (self._res_in is None):
            raise ValueError("provide exactly one of ref_id or resource")
        if self._res_in is not None:
            self.ref_id = self._res_in.key
        return self
    @property
    def resource(self) -> Resource:
        if self._res_cache is None:
            if self._res_in is not None:
                self._res_cache = self._res_in
            else:
                assert self.ref_id is not None
                self._res_cache = resolve_resource(self.ref_id)  # ленивый I/O
        return self._res_cache
С такой схемой модель принимает ровно один из двух входов и поддерживает согласованное внутреннее состояние. Если передать идентификатор, например ResourceCarrier(ref_id=5), дорогой вызов откладывается до первого обращения к resource. Если передать сам объект, например ResourceCarrier(resource=some_res), поле ref_id будет установлено в some_res.key, а загрузка полностью пропускается.
Почему это важно
Этот шаблон закрывает сразу три практические задачи. Во‑первых, он централизованно обеспечивает правило «ровно один вход», устраняя двусмысленное состояние. Во‑вторых, сохраняет ленивость сетевых и базовых вызовов — за I/O платите только тогда, когда объект действительно нужен. И, в‑третьих, после валидации делает идентификатор не‑Optional, если был передан объект, что избавляет последующий код от лишней возни с Optional.
Итоги
Если модель можно указать двумя взаимозаменяемыми способами, используйте обычное поле для ключевых данных, альтернативную форму принимайте через алиас, а проверку XOR сосредотачивайте в валидаторе. Храните внутренний кэш для полученного объекта и предоставляйте свойство с ленивой загрузкой. Так проверка остаётся явной, тяжёлая работа откладывается, а остальная часть кода опирается на стабильную и предсказуемую структуру.