diff --git a/news/7633.bugfix.md b/news/7633.bugfix.md new file mode 100644 index 00000000000..28d97bd6f95 --- /dev/null +++ b/news/7633.bugfix.md @@ -0,0 +1 @@ +Raise a `VarAttributeError` compile-time error when accessing an unserialized property (not decorated with `@pydantic.computed_field`) on a Pydantic model or dataclass within a state var, including when the state var type is a Union. \ No newline at end of file diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index eead8d73f30..7c60f994896 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -972,6 +972,78 @@ def get_attribute_descriptor(cls: GenericType, name: str) -> Any: return None +def is_unserialized_property( + cls: GenericType, + name: str, + descriptor: Any = _NO_DESCRIPTOR, +) -> bool: + """Check whether a property is omitted by the default object serializer. + + Args: + cls: The type being inspected. + name: The attribute name. + descriptor: A previously resolved descriptor, if available. + + Returns: + Whether the property is omitted by the default serializer. + """ + if is_union(cls): + return any( + is_unserialized_property(arg, name) + for arg in get_args(cls) + if arg is not type(None) + ) + + cls = get_origin(cls) or cls + if not isinstance(cls, type): + return False + + attr = ( + get_attribute_descriptor(cls, name) + if descriptor is _NO_DESCRIPTOR + else descriptor + ) + + if not isinstance(attr, property): + return False + + from reflex_base.utils import serializers + + serializer = serializers.get_serializer(cls) + + if dataclasses.is_dataclass(cls): + if serializer is not None: + return False + + return name not in {field.name for field in dataclasses.fields(cls)} + + model_fields = getattr(cls, "model_fields", None) + if not isinstance(model_fields, dict): + model_fields = getattr(cls, "__fields__", None) + + if not isinstance(model_fields, dict): + return False + + pydantic_decorators = getattr(cls, "__pydantic_decorators__", None) + if getattr(pydantic_decorators, "model_serializers", None): + return False + + default_serializers = tuple( + default + for default in ( + getattr(serializers, "serialize_base_model", None), + serializers.serialize_sqlmodel, + ) + if default is not None + ) + if serializer not in default_serializers: + return False + + computed_fields = getattr(cls, "model_computed_fields", {}) or {} + + return name not in model_fields and name not in computed_fields + + def get_attribute_access_type( cls: GenericType, name: str, descriptor: Any = _NO_DESCRIPTOR ) -> GenericType | None: diff --git a/packages/reflex-base/src/reflex_base/vars/object.py b/packages/reflex-base/src/reflex_base/vars/object.py index 8b8504944df..d588384d407 100644 --- a/packages/reflex-base/src/reflex_base/vars/object.py +++ b/packages/reflex-base/src/reflex_base/vars/object.py @@ -329,22 +329,75 @@ def __getattr__(self, name: str) -> Var: var_type = types.value_inside_optional(var_type) fixed_type = get_origin(var_type) or var_type + descriptor = None + is_union_type = types.is_union(var_type) - if isinstance(fixed_type, type) and not safe_issubclass(fixed_type, Mapping): - # Resolve the raw descriptor once and reuse it. A HybridProperty resolves to a - # frontend Var with this object var substituted as `self` (e.g. `State.info.a_b`); - # any other descriptor is passed to `get_attribute_access_type` so the class - # lookup is not repeated. - descriptor = types.get_attribute_descriptor(fixed_type, name) - if isinstance(descriptor, HybridProperty): - hybrid_var = descriptor._get_var(self) - if hybrid_var is None: + if (isinstance(fixed_type, type) or is_union_type) and not safe_issubclass( + fixed_type, Mapping + ): + if not is_union_type: + # Resolve the raw descriptor once and reuse it. A HybridProperty resolves to a + # frontend Var with this object var substituted as `self` (e.g. `State.info.a_b`); + # any other descriptor is passed to `get_attribute_access_type` so the class + # lookup is not repeated. + descriptor = types.get_attribute_descriptor(fixed_type, name) + if isinstance(descriptor, HybridProperty): + hybrid_var = descriptor._get_var(self) + if hybrid_var is None: + msg = ( + f"The hybrid property '{name}' of {fixed_type.__name__} has no " + f"frontend value, so it cannot be accessed on `{self!s}`." + ) + raise VarAttributeError(msg) + return hybrid_var + + if types.is_unserialized_property(var_type, name): + property_types = get_args(var_type) if is_union_type else (fixed_type,) + + property_type = next( + ( + t + for t in property_types + if isinstance(t, type) + and isinstance( + types.get_attribute_descriptor(t, name), property + ) + and types.is_unserialized_property(t, name) + ), + fixed_type, + ) + + if is_union_type: + msg = ( + f"The property '{name}' on union type " + f"'{var_type}' may not be included in serialized state." + ) + elif isinstance(property_type, type) and dataclasses.is_dataclass( + property_type + ): + msg = ( + f"The property '{name}' on dataclass " + f"'{property_type.__name__}' is not included in " + "serialized state. Only declared dataclass fields " + "are serialized." + ) + elif isinstance(property_type, type): msg = ( - f"The hybrid property '{name}' of {fixed_type.__name__} has no " - f"frontend value, so it cannot be accessed on `{self!s}`." + f"The property '{name}' on Pydantic model " + f"'{property_type.__name__}' is not included in " + "serialized state. Use " + "`@pydantic.computed_field` to include the " + "computed value in model_dump()." ) - raise VarAttributeError(msg) - return hybrid_var + else: + msg = ( + f"The property '{name}' on type '{var_type}' " + "may not be included in serialized state." + ) + + raise VarAttributeError(msg) + + if isinstance(fixed_type, type) and not safe_issubclass(fixed_type, Mapping): attribute_type = get_attribute_access_type(var_type, name, descriptor) elif is_typeddict(fixed_type) or fixed_type in types.UnionTypes: attribute_type = get_attribute_access_type(var_type, name) @@ -353,8 +406,8 @@ def __getattr__(self, name: str) -> Var: if attribute_type is None: msg = ( - f"The State var `{self!s}` of type {self._var_type} has no attribute '{name}' or may have been annotated " - f"wrongly." + f"The State var `{self!s}` of type {self._var_type} has no attribute " + f"'{name}' or may have been annotated wrongly." ) raise VarAttributeError(msg) return ObjectItemOperation.create(self, name, attribute_type).guess_type() diff --git a/tests/units/vars/test_object.py b/tests/units/vars/test_object.py index 24d384d1f85..809cd3ee0e8 100644 --- a/tests/units/vars/test_object.py +++ b/tests/units/vars/test_object.py @@ -1,6 +1,6 @@ import dataclasses from collections.abc import Sequence -from typing import Annotated, Any, Literal, assert_type +from typing import Annotated, Any, Literal, Union, assert_type import pytest from reflex_base.utils.exceptions import VarAttributeError @@ -378,3 +378,139 @@ def test_annotated_var_type_is_unwrapped() -> None: assert isinstance(var, ObjectVar) assert var._var_type is Base assert var.quantity._var_type is int + + +def test_plain_property_on_pydantic_model_raises() -> None: + """Plain Pydantic properties are not included in serialized state.""" + + class Item(pydantic.BaseModel): + name: str = "solo" + + @property + def label(self) -> str: + return f"{self.name}!" + + @pydantic.computed_field + @property + def tag(self) -> str: + return f"#{self.name}" + + class ItemState(rx.State): + item: rx.Field[Item] = rx.field(Item()) + + with pytest.raises(VarAttributeError, match="computed_field"): + _ = ItemState.item.label + + assert ItemState.item.name._var_type is str + assert ItemState.item.tag._var_type is str + + +def test_plain_property_on_dataclass_raises() -> None: + """Plain dataclass properties are not included in serialized state.""" + + @dataclasses.dataclass + class Point: + x: int = 1 + + @property + def double(self) -> int: + return self.x * 2 + + class PointState(rx.State): + point: rx.Field[Point] = rx.field(Point()) + + with pytest.raises( + VarAttributeError, + match="declared dataclass fields", + ): + _ = PointState.point.double + + assert PointState.point.x._var_type is int + + +def test_plain_property_on_pydantic_model_with_custom_serializer() -> None: + """Plain properties remain accessible when a custom serializer can include them.""" + + class Item(pydantic.BaseModel): + name: str = "solo" + + @property + def label(self) -> str: + return f"{self.name}!" + + @pydantic.model_serializer + def serialize_model(self) -> dict[str, str]: + return { + "name": self.name, + "label": self.label, + } + + class ItemState(rx.State): + item: rx.Field[Item] = rx.field(Item()) + + assert Item().model_dump() == {"name": "solo", "label": "solo!"} + assert ItemState.item.label._var_type is str + + +def test_plain_property_on_union_of_pydantic_models_raises() -> None: + """Plain properties are rejected when either union branch omits them.""" + + class ItemA(pydantic.BaseModel): + name: str = "a" + + @property + def label(self) -> str: + return f"{self.name}!" + + class ItemB(pydantic.BaseModel): + name: str = "b" + + @property + def label(self) -> str: + return f"{self.name}?" + + class UnionState(rx.State): + item: rx.Field[ItemA | ItemB] = rx.field(ItemA()) + + with pytest.raises(VarAttributeError, match="union type"): + _ = UnionState.item.label + + +def test_unserialized_property_checks_union_branches() -> None: + """The property guard detects an unserialized property in either branch.""" + + from reflex_base.utils import types + + class ItemA(pydantic.BaseModel): + @property + def label(self) -> str: + return "a" + + class ItemB(pydantic.BaseModel): + @property + def label(self) -> str: + return "b" + + assert types.is_unserialized_property(ItemA, "label") + assert types.is_unserialized_property(ItemB, "label") + assert types.is_unserialized_property(ItemA | ItemB, "label") + + +def test_plain_property_on_typing_union_raises() -> None: + """Plain properties are rejected on typing.Union model types.""" + + class ItemA(pydantic.BaseModel): + @property + def label(self) -> str: + return "a" + + class ItemB(pydantic.BaseModel): + @property + def label(self) -> str: + return "b" + + class UnionState(rx.State): + item: rx.Field[Union[ItemA, ItemB]] = rx.field(ItemA()) # noqa: UP007 + + with pytest.raises(VarAttributeError, match="union type"): + _ = UnionState.item.label