Dániel Nagy
Dániel Nagy

Reputation: 12045

Pydantic: Make field None in validator based on other field's value

I'm using the pydantic BaseModel with a validator like this:

from datetime import date
from typing import List, Optional
from pydantic import BaseModel, BaseConfig, validator

class Model(BaseModel):
    class Config(BaseConfig):
        allow_population_by_alias = True
        fields = {
            "some_date": {
                "alias": "some_list"
            }
        }
    some_date: Optional[date]
    some_list: List[date]

    @validator("some_date", pre=True, always=True)
    def validate_date(cls, value):
        if len(value) < 2: # here value is some_list
            return None
        return value[0] # return the first value - let's assume it's a date string

# This reproduces the problem
m = Model(some_list=['2019-01-03'])

I would like to compute the value of some_date based on the value of some_list and make it None if a certain condition met.

My JSON never contains the field some_date, it's always populated based on some_list hence pre=True, always=True. However the default validator for some_date will run after my custom one, which will fail if validate_date returns None.

Is there a way to create such a field which is only computed by another one and still can be Optional?

Upvotes: 44

Views: 74036

Answers (5)

pyInTheSky
pyInTheSky

Reputation: 1479

In pydantic >= 2.0, the third paramater can be added to accept the validation information object ValidationInfo(config={'title': 'Foo'}, context=None, data={'bar': 'hello'}, field_name='baz') (vi in the below example)

from pydantic import BaseModel, field_validator
class Foo(BaseModel):
    bar: str
    baz: int
    @field_validator("baz", mode="before")
    def val_baz(cls, value, vi):
            print(vi)
            if vi.data["bar"] == "hello":
                    raise ValueError("oh no")
            return value
  • Triggers Validation Exception: f = Foo.model_validate({"bar":"hello","baz":1})
  • Success: f = Foo.model_validate({"bar":"hi there","baz":1})

Upvotes: 1

miksus
miksus

Reputation: 3447

What about overriding the __init__?

from datetime import date
from typing import List, Optional
from pydantic import BaseModel

class Model(BaseModel):
    some_date: Optional[date]
    some_list: List[date]

    def __init__(self, *args, **kwargs):

        # Modify the arguments
        if len(kwargs['some_list']) < 2:
            kwargs['some_date'] = None
        else:
            kwargs['some_date'] = kwargs['some_list'][0]

        # Call parent's __init__
        super().__init__(**kwargs)

Model(some_list=['2019-01-03', '2022-01-01'])
# Output: Model(some_date=datetime.date(2019, 1, 3), some_list=[datetime.date(2019, 1, 3), datetime.date(2022, 1, 1)])

Note that if you modify the instance after creation, this validation is not executed.

Upvotes: 1

normanius
normanius

Reputation: 9842

Update: As others pointed out, this can be done now with newer versions (>=0.20). See this answer. (Side note: even the OP's code works now, but doing it without alias is even better.)


From skim reading documentation and source of pydantic, I tend to to say that pydantic's validation mechanism currently has very limited support for type-transformations (list -> date, list -> NoneType) within the validation functions.

Taking a step back, however, your approach using an alias and the flag allow_population_by_alias seems a bit overloaded. some_date is needed only as a shortcut for some_list[0] if len(some_list) >= 2 else None, but it's never set independently from some_list. If that's really the case, why not opting for the following option?

class Model(BaseModel):
    some_list: List[date] = ...

    @property 
    def some_date(self):
        return None if len(self.some_list) < 2 else self.some_list[0]

Upvotes: 9

tupui
tupui

Reputation: 6528

If you want to be able to dynamically modify a field according to another one, you can use the values argument. It holds all the previous fields, and careful: the order matters. You could do this either using a validator or a root_validator.

With a validator

>>> from datetime import date
>>> from typing import List, Optional
>>> from pydantic import BaseModel, validator
>>> class Model(BaseModel):
        some_list: List[date]
        some_date: Optional[date]
    
        @validator("some_date", always=True)
        def validate_date(cls, value, values):
            if len(values["some_list"]) < 2:
                return None
            return values["some_list"][0]

>>> Model(some_list=['2019-01-03', '2020-01-03', '2021-01-03'])
Model(some_list=[datetime.date(2019, 1, 3), datetime.date(2020, 1, 3), datetime.date(2021, 1, 3)],
      some_date=datetime.date(2019, 1, 3))

But as I said if you exchange the order of some_list and some_date, you will have a KeyError: 'some_list'!

With a root_validator

Another option would be to use a root_validator. These act on all fields:

>>> class Model(BaseModel):
        some_list: List[date]
        some_date: Optional[date]
    
        @root_validator
        def validate_date(cls, values):
            if not len(values["some_list"]) < 2:
                values["some_date"] = values["some_list"][0]
            return values

>>> Model(some_list=['2019-01-03', '2020-01-03', '2021-01-03'])
Model(some_list=[datetime.date(2019, 1, 3), datetime.date(2020, 1, 3), datetime.date(2021, 1, 3)],
      some_date=datetime.date(2019, 1, 3))

Upvotes: 67

Bryan Gorges
Bryan Gorges

Reputation: 41

You should be able to use values according to pydantic docs

you can also add any subset of the following arguments to the signature (the names must match):

values: a dict containing the name-to-value mapping of any previously-validated fields

config: the model config

field: the field being validated

**kwargs: if provided, this will include the arguments above not explicitly listed in the signature

@validator()
def set_value_to_zero(cls, v, values):
    # look up other value in values, set v accordingly.

Upvotes: 4

Related Questions