4

How do you update multiple properties on a pydantic model that are validated together and dependent upon each other?

Here is a contrived but simple example:

from pydantic import BaseModel, root_validator

class Example(BaseModel):
    a: int
    b: int

    @root_validator
    def test(cls, values):
        if values['a'] != values['b']:
            raise ValueError('a and b must be equal')  
        return values

    class Config:
        validate_assignment = True

example = Example(a=1, b=1)

example.a = 2 # <-- error raised here because a is 2 and b is still 1
example.b = 2 # <-- don't get a chance to do this

Error:

ValidationError: 1 validation error for Example
__root__
  a and b must be equal (type=value_error)

Both a and b having a value of 2 is valid, but they can't be updated one at a time without triggering the validation error.

Is there a way to put the validation on hold until both are set? Or a way to somehow update both of them at the same time? Thanks!

Zachary Duvall
  • 304
  • 1
  • 9

2 Answers2

2

I found a couple solutions that works well for my use case.

  1. manually triggering the validation and then updating the __dict__ of the pydantic instance directly if it passes -- see update method
  2. a context manager that delays validation until after the context exits -- see delay_validation method
from pydantic import BaseModel, root_validator
from contextlib import contextmanager
import copy

class Example(BaseModel):
    a: int
    b: int

    @root_validator
    def enforce_equal(cls, values):
        if values['a'] != values['b']:
            raise ValueError('a and b must be equal')  
        return values

    class Config:
        validate_assignment = True

    def update(self, **kwargs):
        self.__class__.validate(self.__dict__ | kwargs)
        self.__dict__.update(kwargs)

    @contextmanager
    def delay_validation(self):
        original_dict = copy.deepcopy(self.__dict__)

        self.__config__.validate_assignment = False
        try:
            yield
        finally:
            self.__config__.validate_assignment = True
        
        try:
            self.__class__.validate(self.__dict__)
        except:
            self.__dict__.update(original_dict)
            raise

example = Example(a=1, b=1)

# ================== This didn't work: ===================

# example.a = 2 # <-- error raised here because a is 2 and b is still 1
# example.b = 2 # <-- don't get a chance to do this

# ==================== update method: ====================

# No error raised
example.update(a=2, b=2) 

# Error raised as expected - a and b must be equal
example.update(a=3, b=4) 

# Error raised as expected - a and b must be equal
example.update(a=5) 

# # =============== delay validation method: ===============

# No error raised
with example.delay_validation():
    example.a = 2
    example.b = 2

# Error raised as expected - a and b must be equal
with example.delay_validation():
    example.a = 3
    example.b = 4

# Error raised as expected - a and b must be equal
with example.delay_validation():
    example.a = 5
Zachary Duvall
  • 304
  • 1
  • 9
  • 1
    Saw your reply to my comment on another thread. Yeah this is about as elegant a solution as I've seen. It's a shame pydantic doesn't have a better solution to this problem. To anyone looking for another example scenario: 2 datetime fields (start, stop) with a root validator to enforce start <= stop. – Taylor Vance Apr 03 '23 at 13:48
1

You can make a workaround building a setter.

from pydantic import BaseModel, root_validator


class Example(BaseModel):
    a: int
    b: int


    @root_validator
    def test(cls, values):
        if values['a'] != values['b']:
            raise ValueError('a and b must be equal')
        return values

    class Config:
        validate_assignment = True

    def set_a_and_b(self, value):
        self.Config.validate_assignment = False
        self.a, self.b = value, value
        self.Config.validate_assignment = True

PoC:

>>> example = Example(a=1, b=1)
>>> example.a = 2
Traceback (most recent call last):
  File "D:\temp\venv\lib\site-packages\IPython\core\interactiveshell.py", line 3398, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-4-950b5db07c46>", line 1, in <cell line: 1>
    example.a =2
  File "pydantic\main.py", line 393, in pydantic.main.BaseModel.__setattr__
pydantic.error_wrappers.ValidationError: 1 validation error for Example
__root__
  a and b must be equal (type=value_error)

>>> example.set_a_and_b(2) # <========= workaround 
>>> example
Example(a=2, b=2)
>>> example.a = 3
Traceback (most recent call last):
  File "D:\temp\venv\lib\site-packages\IPython\core\interactiveshell.py", line 3398, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-8-d93e8eb8a0e3>", line 1, in <cell line: 1>
    example.a = 3
  File "pydantic\main.py", line 393, in pydantic.main.BaseModel.__setattr__
pydantic.error_wrappers.ValidationError: 1 validation error for Example
__root__
  a and b must be equal (type=value_error)

But maybe in your real case you should use some setters and getters instead (or with) standard validation

JacekK
  • 623
  • 6
  • 11
  • Thanks for the suggestion @JacekK! Good idea to temporarily overwrite the `validate_assignment` property. Any thoughts on something that will still run validation after updating the attributes? That could be useful for cases when it's trickier to write a simple setter that will always pass the validation. Hoping for some way to change properties on the model while remaining ignorant to what the validation checks. Then the validation runs after the properties are updated. Does that make sense? – Zachary Duvall Sep 15 '22 at 17:29
  • @ZacharyDuvall I don't believe there is a way to be completely ignorant of the validation, but in a similar situation the solution I came up with was to implement the root validation within the setter. So in this case, before re-enabling `validate_assignment` you would want to make the check `if a != b: raise ValueError('a and b must be equal')` – Benjamin Jan 24 '23 at 23:00