diff --git a/news/6617.feature.md b/news/6617.feature.md new file mode 100644 index 00000000000..c6f9f7f276b --- /dev/null +++ b/news/6617.feature.md @@ -0,0 +1 @@ +`rx._x.hybrid_property` now works on dataclasses, pydantic models and SQLAlchemy models, not just `State` classes. Accessing the property through an object var on the frontend (e.g. `State.info.a_b`) renders it as a var, using the same code you already use on the backend. ([#6617](https://github.com/reflex-dev/reflex/issues/6617)) diff --git a/packages/reflex-base/news/6617.feature.md b/packages/reflex-base/news/6617.feature.md new file mode 100644 index 00000000000..e457549824b --- /dev/null +++ b/packages/reflex-base/news/6617.feature.md @@ -0,0 +1 @@ +`ObjectVar` attribute access now resolves `HybridProperty` descriptors defined on the underlying type, evaluating the property's frontend logic with the object var substituted as `self`. `HybridProperty` moved to `reflex_base.vars.hybrid_property` (still available as `rx._x.hybrid_property`). ([#6617](https://github.com/reflex-dev/reflex/issues/6617)) diff --git a/reflex/experimental/hybrid_property.py b/packages/reflex-base/src/reflex_base/vars/hybrid_property.py similarity index 53% rename from reflex/experimental/hybrid_property.py rename to packages/reflex-base/src/reflex_base/vars/hybrid_property.py index dfcda68e2ca..b34350d51b3 100644 --- a/reflex/experimental/hybrid_property.py +++ b/packages/reflex-base/src/reflex_base/vars/hybrid_property.py @@ -3,8 +3,9 @@ from collections.abc import Callable from typing import Any -from reflex.utils.types import Self, override -from reflex.vars.base import Var +from reflex_base.utils.types import Self, override + +from .base import Var class HybridProperty(property): @@ -13,22 +14,23 @@ class HybridProperty(property): # The optional var function for the property. _var: Callable[[Any], Var] | None = None - @override - def __get__(self, instance: Any, owner: type | None = None, /) -> Any: - """Get the value of the property. If the property is not bound to an instance return a frontend Var. + def _get_var(self, owner: Any) -> Var: + """Get the frontend Var for the property. + + The ``owner`` is the object the property is accessed on at the var level: + either the class (for class-level access, e.g. ``State.full_name``) or an + ``ObjectVar`` (for attribute access on an object var, e.g. ``State.info.a_b``). + Attribute access on ``owner`` inside the getter/var function resolves to Vars. Args: - instance: The instance of the class accessing this property. - owner: The class that this descriptor is attached to. + owner: The class or var the property is accessed on. Returns: - The value of the property or a frontend Var. + The frontend Var for the property. Raises: AttributeError: If the property has no getter function and no var function is set. """ - if instance is not None: - return super().__get__(instance, owner) if self._var is not None: # Call custom var function if set return self._var(owner) @@ -38,6 +40,33 @@ def __get__(self, instance: Any, owner: type | None = None, /) -> Any: raise AttributeError(msg) return self.fget(owner) + @override + def __get__(self, instance: Any, owner: type | None = None, /) -> Any: + """Get the value of the property. + + On an instance, return the getter's value. At the class level, return a + frontend Var only when accessed on a state (whose class attributes are + vars); on any other class there is no var context, so return the + descriptor itself, like a normal property. Note that var access on a + nested object (e.g. ``State.info.a_b``) does not go through ``__get__`` — + it is resolved by ``ObjectVar.__getattr__`` via ``_get_var``. + + Args: + instance: The instance of the class accessing this property. + owner: The class that this descriptor is attached to. + + Returns: + The property value, a frontend Var, or the descriptor itself. + """ + if instance is not None: + return super().__get__(instance, owner) + if isinstance(owner, type): + from reflex.state import BaseState + + if issubclass(owner, BaseState): + return self._get_var(owner) + return self + def var(self, func: Callable[[Any], Var]) -> Self: """Set the (optional) var function for the property. diff --git a/packages/reflex-base/src/reflex_base/vars/object.py b/packages/reflex-base/src/reflex_base/vars/object.py index 521771272de..e228f8507a7 100644 --- a/packages/reflex-base/src/reflex_base/vars/object.py +++ b/packages/reflex-base/src/reflex_base/vars/object.py @@ -331,6 +331,17 @@ def __getattr__(self, name: str) -> Var: fixed_type = get_origin(var_type) or var_type + if isinstance(fixed_type, type): + from .hybrid_property import HybridProperty + + # A HybridProperty on the underlying type resolves to a frontend Var with + # this object var substituted as `self` (e.g. `State.info.a_b`). Class-level + # access returns the descriptor itself (it only yields a var on a state), so a + # plain `getattr` surfaces it without the cost of `inspect.getattr_static`. + descriptor = getattr(fixed_type, name, None) + if isinstance(descriptor, HybridProperty): + return descriptor._get_var(self) + if ( is_typeddict(fixed_type) or ( diff --git a/reflex/experimental/__init__.py b/reflex/experimental/__init__.py index 83531ebe15b..77d6aec18fc 100644 --- a/reflex/experimental/__init__.py +++ b/reflex/experimental/__init__.py @@ -5,13 +5,13 @@ from reflex_base.components.memo import memo as _memo from reflex_base.utils.console import warn +from reflex_base.vars.hybrid_property import hybrid_property as hybrid_property from reflex_components_code.shiki_code_block import code_block as code_block from reflex.utils.misc import run_in_thread from . import hooks as hooks from .client_state import ClientStateVar as ClientStateVar -from .hybrid_property import hybrid_property as hybrid_property class ExperimentalNamespace(SimpleNamespace): diff --git a/tests/integration/test_hybrid_properties.py b/tests/integration/test_hybrid_properties.py index 7cb40588522..ae130115ee3 100644 --- a/tests/integration/test_hybrid_properties.py +++ b/tests/integration/test_hybrid_properties.py @@ -13,13 +13,32 @@ def HybridProperties(): """Test app for hybrid properties.""" + from dataclasses import dataclass + import reflex as rx from reflex.experimental import hybrid_property from reflex.vars import Var + @dataclass + class Info: + """A nested dataclass exposing a hybrid property.""" + + a: str + b: str + + @hybrid_property + def a_b(self) -> str: + """Combine the two fields, usable on both frontend and backend. + + Returns: + str: The two fields joined with a dash. + """ + return f"{self.a} - {self.b}" + class State(rx.State): first_name: str = "John" last_name: str = "Doe" + info: Info = Info(a="a", b="b") @property def python_full_name(self) -> str: @@ -84,6 +103,15 @@ def update_last_name(self, value: str): """ self.last_name = value + @rx.event + def update_info_a(self, value: str): + """Update the `a` field of the nested info dataclass. + + Args: + value: The new value for `info.a`. + """ + self.info = Info(a=value, b=self.info.b) + def index() -> rx.Component: return rx.center( rx.vstack( @@ -110,6 +138,12 @@ def index() -> rx.Component: on_change=State.update_last_name, id="set_last_name", ), + rx.text(f"info_a_b: {State.info.a_b}", id="info_a_b"), + rx.el.input( + value=State.info.a, + on_change=State.update_info_a, + id="set_info_a", + ), ), ) @@ -192,6 +226,19 @@ def test_hybrid_properties( assert hybrid_properties.app_instance is not None assert token + info_a_b = driver.find_element(By.ID, "info_a_b") + assert info_a_b.text == "info_a_b: a - b" + + # Updating the nested dataclass re-renders the hybrid property accessed via the object var. + set_info_a = driver.find_element(By.ID, "set_info_a") + set_info_a.send_keys(Keys.CONTROL + "a") + set_info_a.send_keys(Keys.DELETE) + set_info_a.send_keys("z") + assert ( + hybrid_properties.poll_for_content(info_a_b, exp_not_equal="info_a_b: a - b") + == "info_a_b: z - b" + ) + full_name = driver.find_element(By.ID, "full_name") assert full_name.text == "full_name: John Doe" diff --git a/tests/units/vars/test_object.py b/tests/units/vars/test_object.py index e9ab66c82dc..41e7ae8358a 100644 --- a/tests/units/vars/test_object.py +++ b/tests/units/vars/test_object.py @@ -3,9 +3,11 @@ from typing import Any import pytest +from reflex_base.utils.exceptions import VarAttributeError from reflex_base.utils.imports import ImportVar from reflex_base.utils.types import GenericType from reflex_base.vars.base import Var, VarData +from reflex_base.vars.number import NumberVar from reflex_base.vars.object import LiteralObjectVar, ObjectVar, RestProp from reflex_base.vars.sequence import ArrayVar from typing_extensions import assert_type @@ -18,8 +20,49 @@ import pydantic from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column +from reflex.experimental import hybrid_property -class Bare: + +class HybridQuantity: + """Mixin defining hybrid properties on top of a ``quantity`` attribute. + + Used to verify that ``hybrid_property`` resolves uniformly across every kind of + object var (bare class, pydantic model, sqlalchemy model and dataclass). + """ + + # Provided by each subclass; typed loosely so the various concrete declarations + # (e.g. sqlalchemy's `Mapped[int]`) don't conflict with the mixin. + quantity: Any + + @hybrid_property + def doubled(self) -> int: + """A simple hybrid property reusing the same code on frontend and backend. + + Returns: + Twice the quantity. + """ + return self.quantity * 2 + + @hybrid_property + def is_nonzero(self) -> bool: + """A hybrid property whose backend getter differs from the frontend var. + + Returns: + Whether the quantity is non-zero. + """ + return self.quantity != 0 + + @is_nonzero.var + def is_nonzero(cls) -> Var[bool]: + """The frontend var for ``is_nonzero`` (deliberately distinct from the getter). + + Returns: + A Var that is True when the quantity is greater than zero. + """ + return cls.quantity > 0 + + +class Bare(HybridQuantity): """A bare class with a single attribute.""" quantity: int = 0 @@ -38,7 +81,7 @@ def serialize_bare(obj: Bare) -> dict: return {"quantity": obj.quantity} -class Base(pydantic.BaseModel): +class Base(HybridQuantity, pydantic.BaseModel): """A pydantic BaseModel class with a single attribute.""" quantity: int = 0 @@ -48,7 +91,7 @@ class SqlaBase(DeclarativeBase, MappedAsDataclass): """Sqlalchemy declarative mapping base class.""" -class SqlaModel(SqlaBase): +class SqlaModel(HybridQuantity, SqlaBase): """A sqlalchemy model with a single attribute.""" __tablename__: str = "sqla_model" @@ -58,7 +101,7 @@ class SqlaModel(SqlaBase): @dataclasses.dataclass -class Dataclass: +class Dataclass(HybridQuantity): """A dataclass with a single attribute.""" quantity: int = 0 @@ -132,6 +175,90 @@ def test_state_to_operation(type_: GenericType) -> None: assert var._var_type is type_ +@pytest.mark.parametrize("type_", [Base, Bare, SqlaModel, Dataclass]) +def test_hybrid_property_on_object_var(type_: GenericType) -> None: + """A hybrid property on the underlying type resolves with the object var as ``self``. + + Args: + type_: The object type to access the hybrid property on. + """ + obj_var: ObjectVar = getattr(ObjectState, type_.__name__.lower()) + quantity = obj_var.quantity.to(NumberVar) + + # The simple hybrid property reuses its getter, with attribute access resolved + # against the object var (e.g. `obj.doubled` behaves like `obj.quantity * 2`). + assert str(Var.create(obj_var.doubled)) == str(Var.create(quantity * 2)) + + # The custom `.var` function is used for the frontend (not the getter), so the + # result matches `obj.quantity > 0` rather than the getter's `obj.quantity != 0`. + assert str(Var.create(obj_var.is_nonzero)) == str(Var.create(quantity > 0)) + + +def test_hybrid_property_on_object_var_issue_6617() -> None: + """A hybrid property on a nested dataclass renders as if accessed on the state.""" + + @dataclasses.dataclass + class Info: + a: str + b: str + + @hybrid_property + def a_b(self) -> str: + return f"{self.a} - {self.b}" + + class InfoState(rx.State): + info: Info = Info(a="a", b="b") + + assert str(Var.create(InfoState.info.a_b)) == str( + Var.create(f"{InfoState.info.a} - {InfoState.info.b}") + ) + + +def test_hybrid_property_missing_attribute_still_raises() -> None: + """Accessing a non-existent attribute on an object var still raises VarAttributeError.""" + with pytest.raises(VarAttributeError): + _ = ObjectState.dataclass.does_not_exist + + +def test_hybrid_property_class_access_on_non_state_returns_descriptor() -> None: + """Class-level access on a non-state (not via an object var) returns the descriptor. + + Only a state exposes a hybrid property as a frontend var at the class level, since + its class attributes are vars. On a plain class accessed directly there is no var + context, so it behaves like a normal property and returns the descriptor itself. + """ + from reflex_base.vars.hybrid_property import HybridProperty + + @dataclasses.dataclass + class Info: + a: str + b: str + + @hybrid_property + def a_b(self) -> str: + return f"{self.a} - {self.b}" + + # Direct class-level access yields the descriptor, not a var or a default-based value. + assert isinstance(Info.a_b, HybridProperty) + # Instance access still returns the computed backend value. + assert Info(a="1", b="2").a_b == "1 - 2" + + +def test_hybrid_property_class_access_on_state_returns_var() -> None: + """Class-level access on a state still resolves to a frontend var (not the descriptor).""" + from reflex_base.vars.hybrid_property import HybridProperty + + class HybridState(rx.State): + value: str = "v" + + @hybrid_property + def shout(self) -> str: + return self.value + + assert not isinstance(HybridState.shout, HybridProperty) + assert isinstance(Var.create(HybridState.shout), Var) + + def test_typing() -> None: # Bare var = ObjectState.bare.to(ObjectVar)