Skip to content

Commit

Permalink
fix: fix use of computed_field setter with field_dependencies (#336)
Browse files Browse the repository at this point in the history
* fix: fix use of computed_field setter with property_setters

* extend test

* fix error
  • Loading branch information
tlambert03 authored Nov 8, 2024
1 parent fd5a8e8 commit 97c2fc1
Show file tree
Hide file tree
Showing 2 changed files with 62 additions and 1 deletion.
20 changes: 19 additions & 1 deletion src/psygnal/_evented_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
from pydantic import ConfigDict
from pydantic._internal import _model_construction as pydantic_main
from pydantic._internal import _utils as utils
from pydantic._internal._decorators import PydanticDescriptorProxy
from typing_extensions import TypeGuard # py310
from typing_extensions import dataclass_transform as dataclass_transform # py311

from ._signal import SignalInstance
Expand Down Expand Up @@ -133,6 +135,15 @@ def _get_fields(cls: pydantic.BaseModel) -> Dict[str, pydantic.fields.FieldInfo]
def _model_dump(obj: pydantic.BaseModel) -> dict:
return obj.model_dump()

def _is_pydantic_descriptor_proxy(obj: Any) -> "TypeGuard[PydanticDescriptorProxy]":
if (
type(obj).__module__.startswith("pydantic")
and type(obj).__name__ == "PydanticDescriptorProxy"
and isinstance(getattr(obj, "wrapped", None), property)
):
return True
return False

else:

@no_type_check
Expand Down Expand Up @@ -171,6 +182,9 @@ def _get_fields(cls: type) -> Dict[str, FieldInfo]:
def _model_dump(obj: pydantic.BaseModel) -> dict:
return obj.dict()

def _is_pydantic_descriptor_proxy(obj: Any) -> "TypeGuard[PydanticDescriptorProxy]":
return False


class ComparisonDelayer:
"""Context that delays before/after comparisons until exit."""
Expand Down Expand Up @@ -259,10 +273,14 @@ def __new__(
# in EventedModel.__setattr__
cls.__property_setters__ = {}
if allow_props:
# inherit property setters from base classes
for b in reversed(cls.__bases__):
if hasattr(b, "__property_setters__"):
cls.__property_setters__.update(b.__property_setters__)
# add property setters from this class
for key, attr in namespace.items():
if _is_pydantic_descriptor_proxy(attr):
attr = attr.wrapped
if isinstance(attr, property) and attr.fset is not None:
cls.__property_setters__[key] = attr
recursion = emission_map.get(key, default_strategy)
Expand Down Expand Up @@ -335,7 +353,7 @@ class Config:
for prop, fields in cfg_deps.items():
if prop not in {*model_fields, *cls.__property_setters__}:
raise ValueError(
"Fields with dependencies must be fields or property.setters."
"Fields with dependencies must be fields or property.setters. "
f"{prop!r} is not."
)
for field in fields:
Expand Down
43 changes: 43 additions & 0 deletions tests/test_evented_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -939,3 +939,46 @@ class Config:
else:
assert m.events.a._reemission == mode
assert m.events.b._reemission == mode


@pytest.mark.skipif(not PYDANTIC_V2, reason="computed_field added in v2")
def test_computed_field() -> None:
from pydantic import computed_field

class MyModel(EventedModel):
a: int = 1
b: int = 1

@computed_field
@property
def c(self) -> List[int]:
return [self.a, self.b]

@c.setter
def c(self, val: Sequence[int]) -> None:
self.a, self.b = val

model_config = {
"allow_property_setters": True,
"field_dependencies": {"c": ["a", "b"]},
}

mock_a = Mock()
mock_b = Mock()
mock_c = Mock()
m = MyModel()
m.events.a.connect(mock_a)
m.events.b.connect(mock_b)
m.events.c.connect(mock_c)

m.c = [10, 20]
mock_a.assert_called_with(10)
mock_b.assert_called_with(20)
mock_c.assert_called_with([10, 20])

mock_a.reset_mock()
mock_c.reset_mock()

m.a = 5
mock_a.assert_called_with(5)
mock_c.assert_called_with([5, 20])

0 comments on commit 97c2fc1

Please sign in to comment.