pythonpython-attrs

Interlinked validators using python attrs


I want to use attrs to run validators on values inside my class. Here is an example:

from attrs import define, field

@define(kw_only=True)
class Foo:
    x: float = field()
    min_x: float = field()
    max_x: float = field()

    @x.validator
    def validate_x(self, _, value) -> None:
        if value < self.min_x:
            raise ValueError(f"x must be greater than {self.min_x}")
        if value > self.max_x:
            raise ValueError(f"x must be less than {self.max_x}")

    @min_x.validator
    def validate_min_x(self, _, value) -> None:
        if value > self.max_x:
            raise ValueError("Min x must be less than max x")

    @max_x.validator
    def validate_max_x(self, _, value) -> None:
        if value < self.min_x:
            raise ValueError("Max x must be greater than min x")

All three values x, min_x and max_x are important to me and need to be stored. As I want to be able to get all three values and change them when needed. The problem I have is how these three validator functions can work together.

For basic usage, like creating a new instance, it works fine.

foo = Foo(x=5, min_x=0, max_x=10)

But say I wanted to change the values. I might want to do it like this:

foo.min_x = 11
foo.max_x = 15
foo.x = 12

But foo.min_x will throw an error because it will compare to max_x = 10. Yes, if I swap the order and set foo.max_x first it will solve it. But next time I might want to make foo.max_x smaller than foo.min_x so I need a robust way to handle all cases.

Is there a good way to handle this kind of situation?


Solution

  • You are altering all fields of your Foo object, so you are essentially creating a new object. The problem with your stepwise changes to the fields is that in intermediate steps you have illegal state. It is desirable to make illegal state in your class impossible, and your validators actually ensure that.

    The trick here is to change all the parameters at once. You could just instantiate a new object, or, if you want to change only parts of the instance, with the attrs.evolve function which is made for such situations.

    from attrs import define, field, evolve
    
    @define(kw_only=True)
    class Foo:
        x: float = field()
        min_x: float = field()
        max_x: float = field()
    
        @x.validator
        def validate_x(self, _, value) -> None:
            if value < self.min_x:
                raise ValueError(f"x must be greater than {self.min_x}")
            if value > self.max_x:
                raise ValueError(f"x must be less than {self.max_x}")
    
        @min_x.validator
        def validate_min_x(self, _, value) -> None:
            if value > self.max_x:
                raise ValueError("Min x must be less than max x")
    
        @max_x.validator
        def validate_max_x(self, _, value) -> None:
            if value < self.min_x:
                raise ValueError("Max x must be greater than min x")
    
    foo = Foo(x=5, min_x=0, max_x=10)
    print(foo)  # Foo(x=5, min_x=0, max_x=10)
    
    foo = evolve(foo, min_x=11, max_x=15, x=12)
    print(foo)  # Foo(x=12, min_x=11, max_x=15)
    
    foo = evolve(foo, min_x=2, x=4)  # only change x and min_x
    print(foo)  # Foo(x=4, min_x=2, max_x=15)