Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: emission of events from root validators and extraneous emission of dependent fields #234

Merged
merged 10 commits into from
Sep 18, 2023
21 changes: 14 additions & 7 deletions src/psygnal/_evented_model_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,9 @@ class Config:
f"Config property_dependencies must be a dict, not {cfg_deps!r}"
)
for prop, fields in cfg_deps.items():
if prop not in cls.__property_setters__:
if prop not in {*cls.__fields__, *cls.__property_setters__}:
raise ValueError(
"Fields with dependencies must be property.setters."
"Fields with dependencies must be fields or property.setters."
f"{prop!r} is not."
)
for field in fields:
Expand Down Expand Up @@ -344,21 +344,28 @@ def __setattr__(self, name: str, value: Any) -> None:

# grab current value
before = getattr(self, name, object())
# if we have any dependent properties, we need to grab their values
# before we set the new value, so that we can emit events for them
# after the new value is set (only if they have changed).
deps_before: Dict[str, Any] = {
dep: getattr(self, dep) for dep in self.__field_dependents__.get(name, ())
}

# set value using original setter
signal_instance: SignalInstance = getattr(self._events, name)
with signal_instance.blocked():
self._super_setattr_(name, value)

# if different we emit the event with new value
# if the value has changed we emit the event with new value
after = getattr(self, name)

if not _check_field_equality(type(self), name, after, before):
signal_instance.emit(after) # emit event

# emit events for any dependent computed property setters as well
for dep in self.__field_dependents__.get(name, ()):
getattr(self.events, dep).emit(getattr(self, dep))
# also emit events for any dependent computed property setters as well
for dep, before_val in deps_before.items():
after_val = getattr(self, dep)
if not _check_field_equality(type(self), dep, after_val, before_val):
getattr(self._events, dep).emit(after_val)

# expose the private SignalGroup publically
@property
Expand Down
21 changes: 14 additions & 7 deletions src/psygnal/_evented_model_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,9 @@ class Config:
f"Config property_dependencies must be a dict, not {cfg_deps!r}"
)
for prop, fields in cfg_deps.items():
if prop not in cls.__property_setters__:
if prop not in {*cls.model_fields, *cls.__property_setters__}:
raise ValueError(
"Fields with dependencies must be property.setters."
"Fields with dependencies must be fields or property.setters."
f"{prop!r} is not."
)
for field in fields:
Expand Down Expand Up @@ -330,21 +330,28 @@ def __setattr__(self, name: str, value: Any) -> None:

# grab current value
before = getattr(self, name, object())
# if we have any dependent properties, we need to grab their values
# before we set the new value, so that we can emit events for them
# after the new value is set (only if they have changed).
deps_before: Dict[str, Any] = {
dep: getattr(self, dep) for dep in self.__field_dependents__.get(name, ())
}

# set value using original setter
signal_instance: SignalInstance = getattr(self._events, name)
with signal_instance.blocked():
self._super_setattr_(name, value)

# if different we emit the event with new value
# if the value has changed we emit the event with new value
after = getattr(self, name)

if not _check_field_equality(type(self), name, after, before):
signal_instance.emit(after) # emit event

# emit events for any dependent computed property setters as well
for dep in self.__field_dependents__.get(name, ()):
getattr(self.events, dep).emit(getattr(self, dep))
# also emit events for any dependent computed property setters as well
for dep, before_val in deps_before.items():
after_val = getattr(self, dep)
if not _check_field_equality(type(self), dep, after_val, before_val):
getattr(self._events, dep).emit(after_val)

# expose the private SignalGroup publically
@property
Expand Down
63 changes: 56 additions & 7 deletions tests/test_evented_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -542,8 +542,10 @@ def test_evented_model_with_property_setters_events():
assert t.c == [5, 20]


def test_non_setter_with_dependencies():
with pytest.raises(ValueError) as e:
def test_non_setter_with_dependencies() -> None:
with pytest.raises(
ValueError, match="Fields with dependencies must be fields or property.setters"
):

class M(EventedModel):
x: int
Expand All @@ -567,11 +569,9 @@ class Config:
allow_property_setters = True
property_dependencies = {"a": []}

assert "Fields with dependencies must be property.setters" in str(e.value)


def test_unrecognized_property_dependencies():
with pytest.warns(UserWarning) as e:
with pytest.warns(UserWarning, match="Unrecognized field dependency: 'b'"):

class M(EventedModel):
x: int
Expand All @@ -595,8 +595,6 @@ class Config:
allow_property_setters = True
property_dependencies = {"y": ["b"]}

assert "Unrecognized field dependency: 'b'" in str(e[0])


@pytest.mark.skipif(PYDANTIC_V2, reason="pydantic 2 does not support this")
def test_setattr_before_init():
Expand Down Expand Up @@ -687,3 +685,54 @@ class Config:
m.b = 3
mock_a.assert_called_once_with(2)
mock_b.assert_called_once_with(3)


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

if PYDANTIC_V2:
from pydantic import model_validator

model_config = {
"validate_assignment": True,
"property_dependencies": {"y": ["x"]},
}

@model_validator(mode="before")
def check(cls, values: dict) -> dict:
x = values["x"]
values["y"] = min(values["y"], x)
return values

else:
from pydantic import root_validator

class Config:
validate_assignment = True
property_dependencies = {"y": ["x"]}

@root_validator
def check(cls, values: dict) -> dict:
x = values["x"]
values["y"] = min(values["y"], x)
return values

m = Model(x=2, y=1)
xmock = Mock()
ymock = Mock()
m.events.x.connect(xmock)
m.events.y.connect(ymock)
m.x = 0
assert m.y == 0
xmock.assert_called_once_with(0)
ymock.assert_called_once_with(0)

xmock.reset_mock()
ymock.reset_mock()

m.x = 2
assert m.y == 0
xmock.assert_called_once_with(2)
ymock.assert_not_called()