pyapp-kit / psygnal

Python observer pattern (callback/event system). Modeled after Qt Signals & Slots (but independent of Qt)
https://psygnal.readthedocs.io/
BSD 3-Clause "New" or "Revised" License
84 stars 13 forks source link

EventedModel does not emit events for all value changes in a root validator #233

Closed andy-sweet closed 1 year ago

andy-sweet commented 1 year ago

Description

When I define an EventedModel with a root validator that coerces some field values based on others, changes in derived/constrained values do not trigger events to be emitted.

Coming here from an issue on napari where similar problems with its similar EventedModel cause problems for Dims. No fix for that yet.

What I Did

Here's the simplest and also somewhat artificial reproducer I could write, where a model has two ints (x and y) and the root validator ensures x >= y.

from psygnal import EventedModel
from pydantic import root_validator

class Model(EventedModel):
    x: int
    y: int

    class Config:
        validate_assignment = True

    @root_validator
    def check(cls, values):
        x = values['x']
        if values['y'] > x:
            values['y'] = x
        return values

m = Model(x=2, y=1)
m.events.x.connect(lambda x: print(f'x is now {x}'))
m.events.y.connect(lambda y: print(f'y is now {y}'))
print(m)  # => Model(x=2, y=1)
m.x = 0   # x change is printed, but not y
print(m)  # => Model(x=0, y=0)

I think this is a bug because I'd expect the change in y to emit an event.

Thoughts

I'm not sure this is a critical bug because I've at least had half-baked thoughts about alternatives/workarounds (e.g. define x and y as properties and then use property_dependencies). Taking that half-baked idea a little further, does it make any sense to allow property_dependencies to include fields as well as properties?

Other ideas/workarounds are welcome!

tlambert03 commented 1 year ago

thanks as always for the super clear issue! :)

I think we should fix this (without forcing the workaround of making them properties), but it could be done in a bunch of ways, let me know which of the following sounds best to you:

  1. Your idea of allowing property_dependencies to include fields would be the simplest and the most explicit. we would just remove this exception, and allow:
        class Config:
            validate_assignment = True
            property_dependencies = {"y": ["x"]}

    It would be the most performant, and the least magical, but it would mean events.y will always be emitted whenever x is changed, regardless of whether the change in x actually changed the value of y. (for what it's worth, that's already the case with property dependencies anyway)

  2. We could extend that idea to actually check whether the value of y changed before emitting. That would result in the most "expected" behavior, but it would mean that anytime __setattr__ is called on a field, we'd need to fetch the values of all dependent fields before and after actually setting the field, then compare each of them to determine whether an emission is required. So, could be costly in terms of performance.
  3. An alternative to the explicit/mandatory property_dependencies configuration would be to detect whether the class had a root validator, then simply check all fields in the model before and after setting any field. That's of course the worst in terms of performance, but "just does the right thing" without requiring the user to notice this special case.
  4. We could do number 3, but require opt-in with a config setting

thoughts?

tlambert03 commented 1 year ago
  1. as a variant/extension of number 2, we could add an additional config setting that gates the checking. allowing the user to decide whether they'd prefer more accurate event emission, or better peformance
andy-sweet commented 1 year ago

I think (1) then (2) seem like the most attractive to me because they're the simplest to implement. For (1), the property_dependencies name is a bit unfortunate - maybe a rename to field_dependencies or just dependencies might be worth it?

As far as the performance hit of (2), it doesn't seem too bad because that will only be incurred if the user explicitly declared the dependency. And I think this particular usage/bug is probably uncommon. Plus if we're only doing this for fields, I think the performance hit doesn't have to be huge. If we're doing for it properties where we may need to calculate its value before/after rather than just read it, it's more of a problem. Therefore, (5) seems a bit unnecessary.

I don't love (3) because it might useless for most users and the performance penalty could be higher since we're checking all the values before and after.

tlambert03 commented 1 year ago

great, I agree on all accounts. I implemented (2) in #234 (without the config of (5)) and it didn't seem to nudge any of the benchmarks. I agree that the naming is now unfortunate, and I'm up for changing it. I like dependencies, but since the namespace is shared with all the other pydantic stuff, perhaps event_dependencies is safer/clearer? or do you like field_dependencies better than event_dependencies?

tlambert03 commented 1 year ago

renamed to field_dependencies