From 1c0c31914fcf7f149792231abe5fdefcdae4255b Mon Sep 17 00:00:00 2001 From: somiljain2006 Date: Fri, 23 Oct 2026 15:11:17 +0530 Subject: [PATCH 1/6] fix: raise clear error for unserialized properties on dataclass and Pydantic vars --- .../src/reflex_base/utils/types.py | 72 +++++++++++++++++++ .../src/reflex_base/vars/object.py | 20 ++++++ tests/units/vars/test_object.py | 49 +++++++++++++ 3 files changed, 141 insertions(+) diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index eead8d73f30..5a2717b339d 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -971,6 +971,78 @@ def get_attribute_descriptor(cls: GenericType, name: str) -> Any: except NotImplementedError: 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) + + # Dataclasses are serialized from their declared fields by the + # default serializer, not from arbitrary properties. + if dataclasses.is_dataclass(cls): + if serializer is not None: + return False + + return name not in { + field.name for field in dataclasses.fields(cls) + } + + # Pydantic models serialize model fields and computed fields. + 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 + + # Don't diagnose classes whose custom serializer may include properties. + 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 diff --git a/packages/reflex-base/src/reflex_base/vars/object.py b/packages/reflex-base/src/reflex_base/vars/object.py index 8b8504944df..a364c992071 100644 --- a/packages/reflex-base/src/reflex_base/vars/object.py +++ b/packages/reflex-base/src/reflex_base/vars/object.py @@ -345,6 +345,26 @@ def __getattr__(self, name: str) -> Var: ) raise VarAttributeError(msg) return hybrid_var + if types.is_unserialized_property( + fixed_type, name, descriptor + ): + if dataclasses.is_dataclass(fixed_type): + msg = ( + f"The property '{name}' on dataclass " + f"'{fixed_type.__name__}' is not included in " + "serialized state. Only declared dataclass fields " + "are serialized." + ) + else: + msg = ( + f"The property '{name}' on Pydantic model " + f"'{fixed_type.__name__}' is not included in " + "serialized state. Use " + "`@pydantic.computed_field` to include the " + "computed value in model_dump()." + ) + + raise VarAttributeError(msg) 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) diff --git a/tests/units/vars/test_object.py b/tests/units/vars/test_object.py index 24d384d1f85..e8c9c15ea26 100644 --- a/tests/units/vars/test_object.py +++ b/tests/units/vars/test_object.py @@ -378,3 +378,52 @@ 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 + + # Regular fields and computed fields remain accessible. + 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 + + # Declared dataclass fields remain accessible. + assert PointState.point.x._var_type is int \ No newline at end of file From c2520226d52db344ecad4647c3a04b542c5cc059 Mon Sep 17 00:00:00 2001 From: somiljain2006 Date: Fri, 23 Oct 2026 15:20:49 +0530 Subject: [PATCH 2/6] Apply formatting --- packages/reflex-base/src/reflex_base/utils/types.py | 10 +++------- packages/reflex-base/src/reflex_base/vars/object.py | 4 +--- tests/units/vars/test_object.py | 5 ++--- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index 5a2717b339d..c317f6db86e 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -971,6 +971,7 @@ def get_attribute_descriptor(cls: GenericType, name: str) -> Any: except NotImplementedError: return None + def is_unserialized_property( cls: GenericType, name: str, @@ -1010,17 +1011,12 @@ def is_unserialized_property( serializer = serializers.get_serializer(cls) - # Dataclasses are serialized from their declared fields by the - # default serializer, not from arbitrary properties. if dataclasses.is_dataclass(cls): if serializer is not None: return False - return name not in { - field.name for field in dataclasses.fields(cls) - } + return name not in {field.name for field in dataclasses.fields(cls)} - # Pydantic models serialize model fields and computed fields. model_fields = getattr(cls, "model_fields", None) if not isinstance(model_fields, dict): model_fields = getattr(cls, "__fields__", None) @@ -1028,7 +1024,6 @@ def is_unserialized_property( if not isinstance(model_fields, dict): return False - # Don't diagnose classes whose custom serializer may include properties. default_serializers = tuple( default for default in ( @@ -1044,6 +1039,7 @@ def is_unserialized_property( 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 a364c992071..e26e8122913 100644 --- a/packages/reflex-base/src/reflex_base/vars/object.py +++ b/packages/reflex-base/src/reflex_base/vars/object.py @@ -345,9 +345,7 @@ def __getattr__(self, name: str) -> Var: ) raise VarAttributeError(msg) return hybrid_var - if types.is_unserialized_property( - fixed_type, name, descriptor - ): + if types.is_unserialized_property(fixed_type, name, descriptor): if dataclasses.is_dataclass(fixed_type): msg = ( f"The property '{name}' on dataclass " diff --git a/tests/units/vars/test_object.py b/tests/units/vars/test_object.py index e8c9c15ea26..e28bd8de78f 100644 --- a/tests/units/vars/test_object.py +++ b/tests/units/vars/test_object.py @@ -379,6 +379,7 @@ def test_annotated_var_type_is_unwrapped() -> None: 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.""" @@ -400,7 +401,6 @@ class ItemState(rx.State): with pytest.raises(VarAttributeError, match="computed_field"): _ = ItemState.item.label - # Regular fields and computed fields remain accessible. assert ItemState.item.name._var_type is str assert ItemState.item.tag._var_type is str @@ -425,5 +425,4 @@ class PointState(rx.State): ): _ = PointState.point.double - # Declared dataclass fields remain accessible. - assert PointState.point.x._var_type is int \ No newline at end of file + assert PointState.point.x._var_type is int From 30171ff90547f0d458552b4c9a3998b8839d3fa3 Mon Sep 17 00:00:00 2001 From: somiljain2006 Date: Fri, 23 Oct 2026 15:58:59 +0530 Subject: [PATCH 3/6] Handle custom Pydantic serializers when accessing properties --- .../src/reflex_base/utils/types.py | 6 +- .../src/reflex_base/vars/object.py | 47 +++++++++++-- tests/units/vars/test_object.py | 68 +++++++++++++++++++ 3 files changed, 113 insertions(+), 8 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index c317f6db86e..7c60f994896 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -1021,7 +1021,11 @@ def is_unserialized_property( if not isinstance(model_fields, dict): model_fields = getattr(cls, "__fields__", None) - if not isinstance(model_fields, dict): + 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( diff --git a/packages/reflex-base/src/reflex_base/vars/object.py b/packages/reflex-base/src/reflex_base/vars/object.py index e26e8122913..0e1666bf997 100644 --- a/packages/reflex-base/src/reflex_base/vars/object.py +++ b/packages/reflex-base/src/reflex_base/vars/object.py @@ -329,6 +329,7 @@ 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 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 @@ -345,24 +346,56 @@ def __getattr__(self, name: str) -> Var: ) raise VarAttributeError(msg) return hybrid_var - if types.is_unserialized_property(fixed_type, name, descriptor): - if dataclasses.is_dataclass(fixed_type): + + if types.is_unserialized_property(var_type, name): + is_union_type = types.is_union(var_type) + + 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"'{fixed_type.__name__}' is not included in " + f"'{property_type.__name__}' is not included in " "serialized state. Only declared dataclass fields " "are serialized." ) - else: + elif isinstance(property_type, type): msg = ( f"The property '{name}' on Pydantic model " - f"'{fixed_type.__name__}' is not included in " + f"'{property_type.__name__}' is not included in " "serialized state. Use " "`@pydantic.computed_field` to include the " "computed value in model_dump()." ) + 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) @@ -371,8 +404,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 e28bd8de78f..ce19c7bb571 100644 --- a/tests/units/vars/test_object.py +++ b/tests/units/vars/test_object.py @@ -426,3 +426,71 @@ class PointState(rx.State): _ = 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") From 6840fcaf8ab6953a323ed3745a490d3c1c36b693 Mon Sep 17 00:00:00 2001 From: somiljain2006 Date: Fri, 23 Oct 2026 16:15:57 +0530 Subject: [PATCH 4/6] Add news --- news/7633.bugfix.md | 1 + .../src/reflex_base/vars/object.py | 32 +++++++++---------- tests/units/vars/test_object.py | 22 ++++++++++++- 3 files changed, 37 insertions(+), 18 deletions(-) create mode 100644 news/7633.bugfix.md 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/vars/object.py b/packages/reflex-base/src/reflex_base/vars/object.py index 0e1666bf997..55872b7be6b 100644 --- a/packages/reflex-base/src/reflex_base/vars/object.py +++ b/packages/reflex-base/src/reflex_base/vars/object.py @@ -330,26 +330,24 @@ def __getattr__(self, name: str) -> Var: 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: - 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 (isinstance(fixed_type, type) or is_union_type) and not safe_issubclass( + fixed_type, Mapping + ): + if not is_union_type: + 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): - is_union_type = types.is_union(var_type) - property_types = get_args(var_type) if is_union_type else (fixed_type,) property_type = next( diff --git a/tests/units/vars/test_object.py b/tests/units/vars/test_object.py index ce19c7bb571..3e8a302466d 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 @@ -494,3 +494,23 @@ def label(self) -> str: 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()) + + with pytest.raises(VarAttributeError, match="union type"): + _ = UnionState.item.label From e0fd8709e0a34239ed997528c479d6fa64bb687f Mon Sep 17 00:00:00 2001 From: somiljain2006 Date: Fri, 23 Oct 2026 16:17:18 +0530 Subject: [PATCH 5/6] Restore comment --- packages/reflex-base/src/reflex_base/vars/object.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/reflex-base/src/reflex_base/vars/object.py b/packages/reflex-base/src/reflex_base/vars/object.py index 55872b7be6b..d588384d407 100644 --- a/packages/reflex-base/src/reflex_base/vars/object.py +++ b/packages/reflex-base/src/reflex_base/vars/object.py @@ -336,6 +336,10 @@ def __getattr__(self, name: str) -> Var: 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) From a24b9d34960d1d60984e00b95e637e3bc4075289 Mon Sep 17 00:00:00 2001 From: somil jain Date: Fri, 23 Oct 2026 16:23:21 +0530 Subject: [PATCH 6/6] Update tests/units/vars/test_object.py Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- tests/units/vars/test_object.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/units/vars/test_object.py b/tests/units/vars/test_object.py index 3e8a302466d..809cd3ee0e8 100644 --- a/tests/units/vars/test_object.py +++ b/tests/units/vars/test_object.py @@ -510,7 +510,7 @@ def label(self) -> str: return "b" class UnionState(rx.State): - item: rx.Field[Union[ItemA, ItemB]] = rx.field(ItemA()) + item: rx.Field[Union[ItemA, ItemB]] = rx.field(ItemA()) # noqa: UP007 with pytest.raises(VarAttributeError, match="union type"): _ = UnionState.item.label