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/6617.feature.md
Original file line number Diff line number Diff line change
@@ -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))
1 change: 1 addition & 0 deletions packages/reflex-base/news/6617.feature.md
Original file line number Diff line number Diff line change
@@ -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))
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand All @@ -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.

Expand Down
11 changes: 11 additions & 0 deletions packages/reflex-base/src/reflex_base/vars/object.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
2 changes: 1 addition & 1 deletion reflex/experimental/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
masenf marked this conversation as resolved.
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):
Expand Down
47 changes: 47 additions & 0 deletions tests/integration/test_hybrid_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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",
),
),
)

Expand Down Expand Up @@ -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"

Expand Down
135 changes: 131 additions & 4 deletions tests/units/vars/test_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -58,7 +101,7 @@ class SqlaModel(SqlaBase):


@dataclasses.dataclass
class Dataclass:
class Dataclass(HybridQuantity):
"""A dataclass with a single attribute."""

quantity: int = 0
Expand Down Expand Up @@ -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)
Expand Down
Loading