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))
38 changes: 33 additions & 5 deletions packages/reflex-base/src/reflex_base/utils/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,22 +406,50 @@ def get_property_hint(attr: Any | None) -> GenericType | None:
return hints.get("return", None)


def get_attribute_access_type(cls: GenericType, name: str) -> GenericType | None:
_NO_DESCRIPTOR: Any = object()


def get_attribute_descriptor(cls: GenericType, name: str) -> Any:
"""Resolve the raw class attribute for ``name`` without raising.

Centralizes the lookup ``get_attribute_access_type`` performs so a caller that also
needs the descriptor itself (e.g. to detect a property-like class such as
``HybridProperty``) can share one lookup instead of repeating it.

Args:
cls: The class to read the attribute from.
name: The attribute name.

Returns:
The resolved attribute/descriptor, or ``None`` if it is absent.
"""
try:
return getattr(cls, name, None)
except NotImplementedError:
return None


def get_attribute_access_type(
cls: GenericType, name: str, descriptor: Any = _NO_DESCRIPTOR
) -> GenericType | None:
"""Check if an attribute can be accessed on the cls and return its type.

Supports pydantic models, unions, and annotated attributes on rx.Model.

Args:
cls: The class to check.
name: The name of the attribute to check.
descriptor: A pre-resolved descriptor for ``name`` on ``cls`` (from
``get_attribute_descriptor``); resolved here when not provided.

Returns:
The type of the attribute, if accessible, or None
"""
try:
attr = getattr(cls, name, None)
except NotImplementedError:
attr = None
attr = (
get_attribute_descriptor(cls, name)
if descriptor is _NO_DESCRIPTOR
else descriptor
)

if hint := get_property_hint(attr):
return hint
Expand Down
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
37 changes: 21 additions & 16 deletions packages/reflex-base/src/reflex_base/vars/object.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
var_operation,
var_operation_return,
)
from .hybrid_property import HybridProperty
from .number import BooleanVar, NumberVar, raise_unsupported_operand_types
from .sequence import ArrayVar, LiteralArrayVar, StringVar

Expand Down Expand Up @@ -331,23 +332,27 @@ def __getattr__(self, name: str) -> Var:

fixed_type = get_origin(var_type) or var_type

if (
is_typeddict(fixed_type)
or (
isinstance(fixed_type, type)
and not safe_issubclass(fixed_type, Mapping)
)
or (fixed_type in types.UnionTypes)
):
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):
return descriptor._get_var(self)
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)
if attribute_type is None:
msg = (
f"The State var `{self!s}` of type {escape(str(self._var_type))} has no attribute '{name}' or may have been annotated "
f"wrongly."
)
raise VarAttributeError(msg)
return ObjectItemOperation.create(self, name, attribute_type).guess_type()
return ObjectItemOperation.create(self, name).guess_type()
else:
return ObjectItemOperation.create(self, name).guess_type()

if attribute_type is None:
msg = (
f"The State var `{self!s}` of type {escape(str(self._var_type))} has no attribute '{name}' or may have been annotated "
f"wrongly."
)
raise VarAttributeError(msg)
return ObjectItemOperation.create(self, name, attribute_type).guess_type()

def contains(self, key: Var | Any) -> BooleanVar:
"""Check if the object contains a key.
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
Loading
Loading