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?
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)