diff --git a/src/psygnal/_evented_model.py b/src/psygnal/_evented_model.py index 783875a9..4606a3e1 100644 --- a/src/psygnal/_evented_model.py +++ b/src/psygnal/_evented_model.py @@ -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 @@ -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 @@ -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.""" @@ -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) @@ -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: diff --git a/tests/test_evented_model.py b/tests/test_evented_model.py index a3ed013f..125b9b0a 100644 --- a/tests/test_evented_model.py +++ b/tests/test_evented_model.py @@ -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])