pythonpydantic

Pydantic Model with exactly one input out of two optionals


I want a pydantic Model that can take exactly one of two optional arguments, where the missing argument will be calculated from the other.

Consider this:

from pydantic import BaseModel, model_validator
from typing import Optional

class Object:
    id: int

def get_object_by_id(id: int) -> Object:
    ...

class ObjectContent(BaseModel):
    _object_id: Optional[int] = None
    _object: Optional[Object] = None
    
    @model_validator(mode="before")
    @classmethod
    def object_reference(cls, values):
        if values.get("_object_id") is None and values.get("_object") is None:
            raise ValueError("Define either _object_id or _object")

    @property
    def object_id(self) -> int:
        if self._object_id is None:
            self._object_id = self.object.id
        return self._object_id # type check complains that this might still be None
    
    @object_id.setter
    def object_id(self, id: int):
        self._object_id = id
    
    @property
    def object(self) -> Object:
        if self._object is None:
            self._object = get_object_by_id(self.object_id)
        return self._object # type check complains that this might still be None
        
    @object.setter
    def object(self, object: Object):
        self._object = object


ObjectContent(_object_id=5) # I would prefer to initialize with the unprotected argument, i.e. ObjectContent(object_id=5) or ObjectContent(object=my_object)
  1. I would expecte pydantic to have a more concise solution?
  2. Since get_object_from_id would be a costly io-operation it should stay lazy loaded

Solution

  • from __future__ import annotations
    from pydantic import BaseModel, Field, PrivateAttr, model_validator
    from typing import Optional
    
    class Object:
        id: int
    
    def get_object_by_id(id: int) -> Object:
        ...
    
    class ObjectContent(BaseModel):
        object_id: Optional[int] = None
        _object_in: Optional[Object] = Field(default=None, alias="object")
        _obj_cache: Optional[Object] = PrivateAttr(None)
    
        @model_validator(mode="after")
        def _xor_and_fill(self):
            if (self.object_id is None) == (self._object_in is None):
                raise ValueError("provide exactly one of object_id or object")
            if self._object_in is not None:
                self.object_id = self._object_in.id
            return self
    
        @property
        def object(self) -> Object:
            if self._obj_cache is None:
                if self._object_in is not None:
                    self._obj_cache = self._object_in
                else:
                    assert self.object_id is not None
                    self._obj_cache = get_object_by_id(self.object_id)  # lazy IO
            return self._obj_cache