diff --git a/src/psygnal/_evented_model.py b/src/psygnal/_evented_model.py index 783875a9..8584793b 100644 --- a/src/psygnal/_evented_model.py +++ b/src/psygnal/_evented_model.py @@ -12,6 +12,7 @@ NamedTuple, Set, Type, + TypeGuard, Union, cast, no_type_check, @@ -32,6 +33,7 @@ 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 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) diff --git a/tests/test_evented_model.py b/tests/test_evented_model.py index a3ed013f..95f64acd 100644 --- a/tests/test_evented_model.py +++ b/tests/test_evented_model.py @@ -939,3 +939,26 @@ 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"]}, + }