Require one of two optional arguments in pydantic model

Question:

I am developing an flask restufl api using, among others, openapi3, which uses pydantic models for requests and responses. In one case I want to have a request model that can have either an id or a txt object set and, if one of these is set, fulfills some further conditions (e.g., id > 0 and len(txt) == 4).

I tried to use pydantic validators to realize this condition. I came up with the working code below, however, the validator for txt is quite crowded.

I am wondering if there is a cleaner solution, which separates the validations of a.) id, b.) txt, and c.) the condition to have one of these two, but not both set?


from typing import Optional, Any
from pydantic import BaseModel, validator, Field, ValidationError


class SomePath(BaseModel):

    id: Optional[int] = Field(
        default=None
    )
    txt: Optional[str] = Field(
        default=None
    )

    @validator('id')
    def check_id(cls, v):
        if int(v) <= 0:
            raise ValueError(
                f'{v} is not a valid id.'
            )
        return v

    @validator('txt', always=True)
    def check_txt(cls, v, values):
        if v is not None:
            if not len(v) == 4:
                raise ValueError(
                    f'{v} is not a valid txt.'
                )
            if 'id' in values and values['id'] is not None:
                raise ValueError(
                    'Set only id or txt.'
                ) 
            return v
        else:
            if 'id' in values and values['id'] is None:
                raise ValueError(
                    'Either id or txt must be set.'
                )
            return v


# Test cases
path = SomePath(id='1')
assert path.id == 1
assert path.txt is None

path = SomePath(txt='test')
assert path.id is None
assert path.txt == 'test'

try: 
    path = SomePath(id='-1')
except ValidationError as e:
    assert e.errors()[0]['msg'] == '-1 is not a valid id.'

try: 
    path = SomePath(txt='tes')
except ValidationError as e:
    assert e.errors()[0]['msg'] == 'tes is not a valid txt.'

try: 
    path = SomePath(txt='testt')
except ValidationError as e:
    assert e.errors()[0]['msg'] == 'testt is not a valid txt.'

try: 
    path = SomePath()
except ValidationError as e:
    assert e.errors()[0]['msg'] == 'Either id or txt must be set.'

Asked By: marcel h

||

Answers:

You can simplify it a little.

from typing import Optional
from pydantic import BaseModel, Field, ValidationError, root_validator


class SomePath(BaseModel):

    id_: Optional[int] = Field(default=None, ge=0)
    txt: Optional[str] = Field(default=None, min_length=4, max_length=4)

    class Config:
        validate_assignment = True


    @root_validator(pre=True)
    def validate_xor(cls, values): # better name needed ;) 
        if sum([bool(v) for v in values.values()]) != 1:
            raise ValueError('Either id or txt must be set.')
        return values

Firstly, you can validate an integer for id and txt length by Field arguments:

  1. The ge=0 (greater or equal 0) is used to get your non-negative integer.
  2. The min_length and max_length are used to get a 4 character length string.

You will receive a different error message, but in my opinion “ensure this value is greater than or equal to 0” is more precise than generic “v is not valid”
You can remove your id_ validator. You don’t need it any more.

To validate xor you can use root_validator with a slightly simpler version.

You can also add Model Config:

    class Config:
        validate_assignment = True

to your class to prevent bypass validation with assignment after the SomePath instance was created.

Answered By: JacekK
Categories: questions Tags: ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.