Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/7633.bugfix.md
Original file line number Diff line number Diff line change
@@ -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.
72 changes: 72 additions & 0 deletions packages/reflex-base/src/reflex_base/utils/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
return False

computed_fields = getattr(cls, "model_computed_fields", {}) or {}

return name not in model_fields and name not in computed_fields
Comment thread
somiljain2006 marked this conversation as resolved.


def get_attribute_access_type(
cls: GenericType, name: str, descriptor: Any = _NO_DESCRIPTOR
) -> GenericType | None:
Expand Down
83 changes: 68 additions & 15 deletions packages/reflex-base/src/reflex_base/vars/object.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand Down
138 changes: 137 additions & 1 deletion tests/units/vars/test_object.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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