From 0fef927af667b55177a5806d71df17466212ded0 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 26 Jul 2026 02:46:40 +0200 Subject: [PATCH 1/3] improve hybrid property --- .../src/reflex_base/utils/types.py | 5 +- .../reflex-base/src/reflex_base/vars/base.py | 19 +- .../src/reflex_base/vars/dep_tracking.py | 5 +- .../src/reflex_base/vars/hybrid_property.py | 261 ++++++++++++++++-- .../src/reflex_base/vars/object.py | 9 +- reflex/state.py | 28 +- tests/units/vars/test_hybrid_property.py | 139 ++++++++++ 7 files changed, 440 insertions(+), 26 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/utils/types.py b/packages/reflex-base/src/reflex_base/utils/types.py index 85c769523a0..6a2eb8cc89d 100644 --- a/packages/reflex-base/src/reflex_base/utils/types.py +++ b/packages/reflex-base/src/reflex_base/utils/types.py @@ -404,7 +404,10 @@ def get_property_hint(attr: Any | None) -> GenericType | None: Returns: The type hint of the property, if it is a property, else None. """ - if not isinstance(attr, PROPERTY_CLASSES): + # imported lazily: reflex_base.vars imports this module + from reflex_base.vars.hybrid_property import HybridProperty + + if not isinstance(attr, (*PROPERTY_CLASSES, HybridProperty)): return None hints = get_type_hints(attr.fget) return hints.get("return", None) diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index e2cc7e9156d..9f9f16aafaa 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -3725,6 +3725,20 @@ def field( ) +@once +def _hybrid_property_cls() -> type: + """Get the HybridProperty class. + + Imported lazily because `hybrid_property` imports this module. + + Returns: + The HybridProperty class. + """ + from .hybrid_property import HybridProperty + + return HybridProperty + + @dataclass_transform(kw_only_default=True, field_specifiers=(field,)) class BaseStateMeta(ABCMeta): """Meta class for BaseState.""" @@ -3800,7 +3814,10 @@ def __new__( elif ( not key.startswith("__") and not callable(value) - and not isinstance(value, (staticmethod, classmethod, property, Var)) + and not isinstance( + value, + (staticmethod, classmethod, property, _hybrid_property_cls(), Var), + ) ): if types.is_immutable(value): new_value = Field( diff --git a/packages/reflex-base/src/reflex_base/vars/dep_tracking.py b/packages/reflex-base/src/reflex_base/vars/dep_tracking.py index 7f2a9fd5c7a..aa5d3e44e04 100644 --- a/packages/reflex-base/src/reflex_base/vars/dep_tracking.py +++ b/packages/reflex-base/src/reflex_base/vars/dep_tracking.py @@ -160,6 +160,7 @@ def load_attr_or_method(self, instruction: dis.Instruction) -> None: does not reference a BaseState. """ from .base import ComputedVar + from .hybrid_property import HybridProperty if instruction.argval in self.INVALID_NAMES: msg = f"Cached var {self!s} cannot access arbitrary state via `{instruction.argval}`." @@ -193,7 +194,9 @@ def load_attr_or_method(self, instruction: dis.Instruction) -> None: except AttributeError: static_obj = None - if isinstance(static_obj, property) and not isinstance(static_obj, ComputedVar): + if isinstance(static_obj, (property, HybridProperty)) and not isinstance( + static_obj, ComputedVar + ): # recurse into property fget functions ref_obj = static_obj.fget else: diff --git a/packages/reflex-base/src/reflex_base/vars/hybrid_property.py b/packages/reflex-base/src/reflex_base/vars/hybrid_property.py index ee05f933b8a..77b01fca94d 100644 --- a/packages/reflex-base/src/reflex_base/vars/hybrid_property.py +++ b/packages/reflex-base/src/reflex_base/vars/hybrid_property.py @@ -1,13 +1,23 @@ """hybrid_property decorator which functions like a normal python property but additionally allows (class-level) access from the frontend. You can use the same code for frontend and backend, or implement 2 different methods.""" +from __future__ import annotations + from collections.abc import Callable -from typing import Any +from typing import Any, Generic, cast, overload + +from typing_extensions import TypeVar from reflex_base.utils.exceptions import HybridPropertyError -from reflex_base.utils.types import Self, override from .base import Var +_T = TypeVar("_T") +_O = TypeVar("_O") +# Without a var function the frontend value is whatever running the getter +# against vars produces, and a var function may declare there is none. +_V = TypeVar("_V", default=Var[Any] | None) +_V2 = TypeVar("_V2", bound="Var[Any] | None") + class _StateBackendVarGuard: """Proxy around a state class used while building a hybrid property's frontend var. @@ -53,13 +63,71 @@ def __getattr__(self, name: str) -> Any: return getattr(state_cls, name) -class HybridProperty(property): - """A hybrid property that can also be used in frontend/as var.""" +class HybridProperty(Generic[_T, _O, _V]): + """A hybrid property that can also be used in frontend/as var. + + Deliberately not a `property` subclass: type checkers hard-code class-level + access on properties to the descriptor itself, which would hide the frontend + var this descriptor returns there. `_T` is the value the getter returns on an + instance, `_O` the class the property is defined on, and `_V` the frontend var + returned when the property is accessed on the class. + """ + + def __init__( + self, + fget: Callable[[_O], _T] | None = None, + fset: Callable[[_O, _T], None] | None = None, + fdel: Callable[[_O], None] | None = None, + doc: str | None = None, + ) -> None: + """Initialize the hybrid property. - # The optional var function for the property. - _var: Callable[[Any], Var] | None = None + Args: + fget: The getter, returning the python value on an instance. + fset: The optional setter. + fdel: The optional deleter. + doc: The docstring, taken from `fget` when not given. + """ + self.fget = fget + self.fset = fset + self.fdel = fdel + self.__doc__ = doc if doc is not None else getattr(fget, "__doc__", None) + # The optional var function for the property. + self._var: Callable[[Any], Var[Any] | None] | None = None + # The attribute name the property is bound to, for error messages and + # for rebinding when the var function is defined under another name. + self._name: str | None = getattr(fget, "__name__", None) + + def __set_name__(self, owner: type, name: str, /) -> None: + """Record the attribute name the property is bound to. + + Args: + owner: The class the property is defined on. + name: The attribute name the property is bound to. + """ + self._name = name + + @property + def _property_name(self) -> str: + """The name of the property, for error messages. + + Returns: + The bound attribute name, or a generic placeholder. + """ + return self._name or "hybrid_property" + + def _copy(self) -> HybridProperty[_T, _O, _V]: + """Copy the property, so each class gets its own descriptor. + + Returns: + A copy of this property. + """ + new = type(self)(self.fget, self.fset, self.fdel, self.__doc__) + new._var = self._var + new._name = self._name + return new - def _get_var(self, owner: Any) -> Var: + def _get_var(self, owner: Any) -> Var[Any] | None: """Get the frontend Var for the property. The ``owner`` is the object the property is accessed on at the var level: @@ -71,7 +139,8 @@ def _get_var(self, owner: Any) -> Var: owner: The class or var the property is accessed on. Returns: - The frontend Var for the property. + The frontend Var for the property, or None if the var function + declares that the property has no frontend value on ``owner``. Raises: AttributeError: If the property has no getter function and no var function is set. @@ -81,11 +150,17 @@ def _get_var(self, owner: Any) -> Var: return self._var(owner) # Call the property getter function if no custom var function is set if self.fget is None: - msg = "HybridProperty has no getter function" + msg = f"Hybrid property '{self._property_name}' has no getter function" raise AttributeError(msg) - return self.fget(owner) + # the getter runs against vars here, so it returns the frontend var + return cast("Var[Any] | None", self.fget(owner)) + + @overload + def __get__(self, instance: None, owner: type, /) -> _V: ... + + @overload + def __get__(self, instance: _O, owner: type | None = None, /) -> _T: ... - @override def __get__(self, instance: Any, owner: type | None = None, /) -> Any: """Get the value of the property. @@ -104,28 +179,126 @@ def __get__(self, instance: Any, owner: type | None = None, /) -> Any: The property value, a frontend Var, or the descriptor itself. Raises: + AttributeError: If the property has no getter function. HybridPropertyError: If the frontend logic reads a backend-only state var. """ if instance is not None: - return super().__get__(instance, owner) + if self.fget is None: + msg = f"Hybrid property '{self._property_name}' has no getter function" + raise AttributeError(msg) + return self.fget(instance) if isinstance(owner, type): from reflex.state import BaseState if issubclass(owner, BaseState): if not owner.backend_vars: return self._get_var(owner) - property_name = ( - self.fget.__name__ if self.fget is not None else "hybrid_property" - ) - return self._get_var(_StateBackendVarGuard(owner, property_name)) + return self._get_var(_StateBackendVarGuard(owner, self._property_name)) return self - def var(self, func: Callable[[Any], Var]) -> Self: + def __set__(self, instance: _O, value: _T, /) -> None: + """Set the value of the property. + + Args: + instance: The instance to set the value on. + value: The value to set. + + Raises: + AttributeError: If the property has no setter function. + """ + if self.fset is None: + msg = f"Hybrid property '{self._property_name}' has no setter" + raise AttributeError(msg) + self.fset(instance, value) + + def __delete__(self, instance: _O, /) -> None: + """Delete the value of the property. + + Args: + instance: The instance to delete the value on. + + Raises: + AttributeError: If the property has no deleter function. + """ + if self.fdel is None: + msg = f"Hybrid property '{self._property_name}' has no deleter" + raise AttributeError(msg) + self.fdel(instance) + + def getter(self, fget: Callable[[_O], _T]) -> HybridProperty[_T, _O, _V]: + """Set the getter function of the property. + + Args: + fget: The getter function to set. + + Returns: + A new property instance with the getter function set. + """ + new = self._copy() + new.fget = fget + return new + + def setter(self, fset: Callable[[_O, _T], None]) -> HybridProperty[_T, _O, _V]: + """Set the setter function of the property. + + Args: + fset: The setter function to set. + + Returns: + A new property instance with the setter function set. + """ + new = self._copy() + new.fset = fset + return new + + def deleter(self, fdel: Callable[[_O], None]) -> HybridProperty[_T, _O, _V]: + """Set the deleter function of the property. + + Args: + fdel: The deleter function to set. + + Returns: + A new property instance with the deleter function set. + """ + new = self._copy() + new.fdel = fdel + return new + + @overload + def var( + self, func: classmethod[_O, ..., _V2], / + ) -> HybridProperty[_T, _O, _V2]: ... + + @overload + def var(self, func: staticmethod[..., _V2], /) -> HybridProperty[_T, _O, _V2]: ... + + @overload + def var(self, func: Callable[[Any], _V2], /) -> HybridProperty[_T, _O, _V2]: ... + + def var(self, func: Any, /) -> Any: """Set the (optional) var function for the property. - Returns a new HybridProperty with the same getter/setter/deleter so - that each class gets its own descriptor — matching how property.setter - behaves and preventing shared-mixin mutation across subclasses. + Returns a new HybridProperty with the same getter/setter/deleter so that + each class gets its own descriptor, preventing shared-mixin mutation + across subclasses. The var function receives the class (not an instance), + and may return None to declare that the property has no frontend value on + that class, e.g. when it depends on configuration the class does not + enable. Declaring it a `classmethod` types its first parameter as the + class without repeating the annotation. + + Redeclaring the property's name keeps the frontend var's type visible on + class-level access: + + @hybrid_property + def total_pages(self) -> int | None: ... + + @total_pages.var + @classmethod + def total_pages(cls) -> Var[int] | None: ... + + The result also binds itself under the name of the property it was + created from, so the var function may instead be defined under a name of + its own (at the cost of the frontend var's type on class access). Args: func: The var function to set. @@ -133,9 +306,55 @@ def var(self, func: Callable[[Any], Var]) -> Self: Returns: A new property instance with the var function set. """ - new = type(self)(self.fget, self.fset, self.fdel, self.__doc__) + if isinstance(func, (classmethod, staticmethod)): + func = func.__func__ + new = _HybridPropertyVarBinding(self) new._var = func return new +class _HybridPropertyVarBinding(HybridProperty[_T, _O, _V]): + """The descriptor `HybridProperty.var` returns, which rebinds itself to the property's name. + + A var function is commonly defined under a name of its own so it does not + shadow the property's declaration. This descriptor puts the finished property + back under the property's name when the class body is evaluated, and removes + itself from the alias name. + """ + + def __init__(self, prop: HybridProperty[_T, _O, Any]) -> None: + """Initialize the binding from the property it extends. + + Args: + prop: The property the var function was defined for. + """ + super().__init__(prop.fget, prop.fset, prop.fdel, prop.__doc__) + self._name = prop._name + + def __set_name__(self, owner: type, name: str, /) -> None: + """Bind the finished property under the name of the property it extends. + + Args: + owner: The class the var function is defined on. + name: The attribute name the var function is bound to. + + Raises: + HybridPropertyError: If the property it extends has no known name. + """ + target = self._name + if target is None: + msg = ( + f"The var function '{name}' of '{owner.__name__}' extends a hybrid " + f"property with no name; define the property in a class body or with " + f"a named getter function." + ) + raise HybridPropertyError(msg) + bound = HybridProperty(self.fget, self.fset, self.fdel, self.__doc__) + bound._var = self._var + bound._name = target + setattr(owner, target, bound) + if name != target: + delattr(owner, name) + + hybrid_property = HybridProperty diff --git a/packages/reflex-base/src/reflex_base/vars/object.py b/packages/reflex-base/src/reflex_base/vars/object.py index bf10fdbb993..391676d356f 100644 --- a/packages/reflex-base/src/reflex_base/vars/object.py +++ b/packages/reflex-base/src/reflex_base/vars/object.py @@ -339,7 +339,14 @@ def __getattr__(self, name: str) -> Var: # lookup is not repeated. descriptor = types.get_attribute_descriptor(fixed_type, name) if isinstance(descriptor, HybridProperty): - return descriptor._get_var(self) + 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 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/reflex/state.py b/reflex/state.py index f372d2db903..ffc2fa48bcf 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -65,6 +65,7 @@ dispatch, is_computed_var, ) +from reflex_base.vars.hybrid_property import HybridProperty from rich.markup import escape from typing_extensions import Self @@ -344,6 +345,7 @@ def _is_user_descriptor(value: Any) -> bool: classmethod, staticmethod, property, + HybridProperty, functools.cached_property, EventHandler, Var, @@ -936,6 +938,30 @@ def _init_var_dependency_dicts(cls): # Reset cached schema value cls._to_schema.cache_clear() + @classmethod + def _iter_functions(cls) -> Iterator[tuple[str, FunctionType]]: + """Iterate over the functions defined on the class and its bases. + + Equivalent to `inspect.getmembers(cls, inspect.isfunction)`, except that + the class dicts are read directly instead of going through `getattr`, so + descriptors are not evaluated. Evaluating them here would run user code + (e.g. a hybrid property building its frontend var) while the class is + still being constructed. + + Yields: + The name and function of each function defined on the class or its bases. + """ + seen: set[str] = set() + for klass in cls.__mro__: + for name, value in klass.__dict__.items(): + if name in seen: + continue + seen.add(name) + if isinstance(value, staticmethod): + value = value.__func__ + if isinstance(value, FunctionType): + yield name, value + @classmethod def _check_overridden_methods(cls): """Check for shadow methods and raise error if any. @@ -945,7 +971,7 @@ def _check_overridden_methods(cls): """ overridden_methods = set() state_base_functions = cls._get_base_functions() - for name, method in inspect.getmembers(cls, inspect.isfunction): + for name, method in cls._iter_functions(): # Check if the method is overridden and not a dunder method if ( not name.startswith("__") diff --git a/tests/units/vars/test_hybrid_property.py b/tests/units/vars/test_hybrid_property.py index bbd72ade340..e3605110395 100644 --- a/tests/units/vars/test_hybrid_property.py +++ b/tests/units/vars/test_hybrid_property.py @@ -109,3 +109,142 @@ class ObjVarState(rx.State): info: Info = Info(a="a") assert isinstance(Var.create(ObjVarState.info.combined), Var) + + +def test_hybrid_property_not_evaluated_during_class_creation(): + """A hybrid property must not be evaluated while its state class is built. + + State creation introspects the class; if that resolved hybrid properties, the + var function would run against a half-built class, and any side effect it has + (e.g. reading user configuration) would happen far too early. + """ + calls: list[str] = [] + + class LazyState(rx.State): + count: int = 0 + _secret: str = "hidden" + + @hybrid_property + def doubled(self) -> int: + return self.count * 2 + + @doubled.var + def _doubled_var(cls) -> Var[int]: + calls.append("var") + return cls.count * 2 # pyright: ignore[reportReturnType] + + assert calls == [] + _ = LazyState.doubled + assert calls == ["var"] + + +def test_hybrid_property_var_fn_under_own_name(): + """A var function defined under its own name binds to the property's name.""" + + class AliasState(rx.State): + count: int = 0 + + @hybrid_property + def doubled(self) -> int: + return self.count * 2 + + @doubled.var + def _doubled_var(cls) -> Var[int]: + return cls.count * 3 # pyright: ignore[reportReturnType] + + # the alias does not linger on the class + assert "_doubled_var" not in AliasState.__dict__ + assert AliasState(_reflex_internal_init=True).doubled == 0 + assert str(Var.create(AliasState.doubled)) == str(Var.create(AliasState.count * 3)) + + +def test_hybrid_property_var_fn_may_return_none(): + """A var function returning None means the property has no frontend value.""" + + class NoFrontendState(rx.State): + count: int = 0 + + @hybrid_property + def maybe(self) -> int: + return self.count + + @maybe.var + def _maybe_var(cls) -> Var[int] | None: + return None + + assert NoFrontendState.maybe is None + assert NoFrontendState(_reflex_internal_init=True).maybe == 0 + + +def test_hybrid_property_none_on_object_var_raises(): + """A hybrid property without a frontend value cannot be accessed on an object var.""" + from dataclasses import dataclass + + from reflex_base.utils.exceptions import VarAttributeError + + @dataclass + class Info: + a: str + + @hybrid_property + def combined(self) -> str: + return self.a + + @combined.var + def _combined_var(cls) -> Var[str] | None: + return None + + class NoneObjVarState(rx.State): + info: Info = Info(a="a") + + with pytest.raises(VarAttributeError, match="combined"): + _ = NoneObjVarState.info.combined + + +def test_hybrid_property_setter_and_deleter(): + """setter/deleter keep working like on a plain property.""" + seen: list[str] = [] + + class Holder: + def __init__(self) -> None: + self._value = "a" + + @hybrid_property + def value(self) -> str: + return self._value + + @value.setter + def value(self, new: str) -> None: + self._value = new + + @value.deleter + def value(self) -> None: + seen.append("deleted") + + holder = Holder() + assert holder.value == "a" + holder.value = "b" + assert holder.value == "b" + del holder.value + assert seen == ["deleted"] + + +def test_hybrid_property_var_fn_as_classmethod(): + """A var function may be declared a classmethod, which types its first parameter.""" + + class ClassmethodVarState(rx.State): + count: int = 0 + + @hybrid_property + def doubled(self) -> int: # pyright: ignore[reportRedeclaration] + return self.count * 2 + + @doubled.var + @classmethod + def doubled(cls) -> Var[int]: + return cls.count * 4 # pyright: ignore[reportReturnType] + + assert ClassmethodVarState(_reflex_internal_init=True).doubled == 0 + assert str(Var.create(ClassmethodVarState.doubled)) == str( + Var.create(ClassmethodVarState.count * 4) + ) From 2a7fe5c3ed7b5daa29c688787ab8d09ef5755155 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 26 Jul 2026 12:04:37 +0200 Subject: [PATCH 2/3] some fixes --- news/6812.bugfix.md | 1 + packages/reflex-base/news/6812.feature.md | 1 + .../src/reflex_base/vars/hybrid_property.py | 55 +++++++++++-------- tests/integration/test_hybrid_properties.py | 2 +- tests/units/vars/test_dep_tracking.py | 2 +- tests/units/vars/test_hybrid_property.py | 18 +++--- tests/units/vars/test_object.py | 2 +- 7 files changed, 45 insertions(+), 36 deletions(-) create mode 100644 news/6812.bugfix.md create mode 100644 packages/reflex-base/news/6812.feature.md diff --git a/news/6812.bugfix.md b/news/6812.bugfix.md new file mode 100644 index 00000000000..f30efd475d5 --- /dev/null +++ b/news/6812.bugfix.md @@ -0,0 +1 @@ +State classes no longer resolve descriptors while being constructed, so a hybrid property's frontend var is no longer built against a half-built class. diff --git a/packages/reflex-base/news/6812.feature.md b/packages/reflex-base/news/6812.feature.md new file mode 100644 index 00000000000..2aab0c0800b --- /dev/null +++ b/packages/reflex-base/news/6812.feature.md @@ -0,0 +1 @@ +`hybrid_property` is no longer a `property` subclass, so class-level access now resolves to the frontend var's type instead of the descriptor. Its var function may be declared a `classmethod`, may return `None` to declare that the property has no frontend value on a class, and — like `getter`, `setter` and `deleter` — may be defined under a name of its own instead of shadowing the property's declaration. diff --git a/packages/reflex-base/src/reflex_base/vars/hybrid_property.py b/packages/reflex-base/src/reflex_base/vars/hybrid_property.py index 77b01fca94d..892c1d2c52a 100644 --- a/packages/reflex-base/src/reflex_base/vars/hybrid_property.py +++ b/packages/reflex-base/src/reflex_base/vars/hybrid_property.py @@ -116,13 +116,19 @@ def _property_name(self) -> str: """ return self._name or "hybrid_property" - def _copy(self) -> HybridProperty[_T, _O, _V]: - """Copy the property, so each class gets its own descriptor. + def _derive(self) -> _HybridPropertyBinding[_T, _O, _V]: + """Copy the property into a binding, so each class gets its own descriptor. + + The copy is a binding rather than a plain property so that whichever + decorator produced it (`getter`, `setter`, `deleter` or `var`) may be + defined under a name of its own without losing the property's name. Returns: - A copy of this property. + A copy of this property that rebinds under the property's name. """ - new = type(self)(self.fget, self.fset, self.fdel, self.__doc__) + new: _HybridPropertyBinding[_T, _O, _V] = _HybridPropertyBinding( + self.fget, self.fset, self.fdel, self.__doc__ + ) new._var = self._var new._name = self._name return new @@ -234,7 +240,7 @@ def getter(self, fget: Callable[[_O], _T]) -> HybridProperty[_T, _O, _V]: Returns: A new property instance with the getter function set. """ - new = self._copy() + new = self._derive() new.fget = fget return new @@ -247,7 +253,7 @@ def setter(self, fset: Callable[[_O, _T], None]) -> HybridProperty[_T, _O, _V]: Returns: A new property instance with the setter function set. """ - new = self._copy() + new = self._derive() new.fset = fset return new @@ -260,7 +266,7 @@ def deleter(self, fdel: Callable[[_O], None]) -> HybridProperty[_T, _O, _V]: Returns: A new property instance with the deleter function set. """ - new = self._copy() + new = self._derive() new.fdel = fdel return new @@ -308,35 +314,36 @@ def total_pages(cls) -> Var[int] | None: ... """ if isinstance(func, (classmethod, staticmethod)): func = func.__func__ - new = _HybridPropertyVarBinding(self) + new = self._derive() new._var = func return new -class _HybridPropertyVarBinding(HybridProperty[_T, _O, _V]): - """The descriptor `HybridProperty.var` returns, which rebinds itself to the property's name. +class _HybridPropertyBinding(HybridProperty[_T, _O, _V]): + """The descriptor the `getter`, `setter`, `deleter` and `var` decorators return. - A var function is commonly defined under a name of its own so it does not + Those functions are commonly defined under a name of their own so they do not shadow the property's declaration. This descriptor puts the finished property back under the property's name when the class body is evaluated, and removes itself from the alias name. - """ - def __init__(self, prop: HybridProperty[_T, _O, Any]) -> None: - """Initialize the binding from the property it extends. + When several of them are used on one property, each must decorate the result of + the previous one, since a copy carries only what the property it was derived + from had:: - Args: - prop: The property the var function was defined for. - """ - super().__init__(prop.fget, prop.fset, prop.fdel, prop.__doc__) - self._name = prop._name + @value.setter + def _value_setter(self, new: str) -> None: ... + + @_value_setter.deleter + def _value_deleter(self) -> None: ... + """ def __set_name__(self, owner: type, name: str, /) -> None: """Bind the finished property under the name of the property it extends. Args: - owner: The class the var function is defined on. - name: The attribute name the var function is bound to. + owner: The class the decorated function is defined on. + name: The attribute name the decorated function is bound to. Raises: HybridPropertyError: If the property it extends has no known name. @@ -344,9 +351,9 @@ def __set_name__(self, owner: type, name: str, /) -> None: target = self._name if target is None: msg = ( - f"The var function '{name}' of '{owner.__name__}' extends a hybrid " - f"property with no name; define the property in a class body or with " - f"a named getter function." + f"'{name}' of '{owner.__name__}' extends a hybrid property with no " + f"name; define the property in a class body or with a named getter " + f"function." ) raise HybridPropertyError(msg) bound = HybridProperty(self.fget, self.fset, self.fdel, self.__doc__) diff --git a/tests/integration/test_hybrid_properties.py b/tests/integration/test_hybrid_properties.py index 3064a119535..a800a5e7965 100644 --- a/tests/integration/test_hybrid_properties.py +++ b/tests/integration/test_hybrid_properties.py @@ -68,7 +68,7 @@ def has_last_name(self) -> str: return "yes" if self.last_name else "no" @has_last_name.var - def has_last_name(cls) -> Var[str]: + def _has_last_name_var(cls) -> Var[str]: """The frontend code for the `has_last_name` hybrid property. Returns: diff --git a/tests/units/vars/test_dep_tracking.py b/tests/units/vars/test_dep_tracking.py index a362259587c..6c3c1782316 100644 --- a/tests/units/vars/test_dep_tracking.py +++ b/tests/units/vars/test_dep_tracking.py @@ -472,7 +472,7 @@ def has_last_name(self) -> str: return "yes" if self.last_name else "no" @has_last_name.var - def has_last_name(cls) -> Var[str]: + def _has_last_name_var(cls) -> Var[str]: # Reference an unrelated field here to confirm the tracker uses fget, not this. return cls.unrelated # pyright: ignore[reportReturnType] diff --git a/tests/units/vars/test_hybrid_property.py b/tests/units/vars/test_hybrid_property.py index e3605110395..c98f71aacfd 100644 --- a/tests/units/vars/test_hybrid_property.py +++ b/tests/units/vars/test_hybrid_property.py @@ -35,7 +35,7 @@ def value(self) -> str: return self.name @value.var - def value(cls) -> Var[str]: + def _value_var(cls) -> Var[str]: return cls._secret # pyright: ignore[reportReturnType] with pytest.raises(HybridPropertyError, match="_secret"): @@ -72,8 +72,8 @@ class StateA(Mixin, rx.State): first: str = "a" last: str = "b" - @Mixin.full.var - def full(cls) -> Var: + @original.var + def _full_var(cls) -> Var: return cls.first # pyright: ignore[reportReturnType] class StateB(Mixin, rx.State): @@ -154,7 +154,7 @@ def _doubled_var(cls) -> Var[int]: # the alias does not linger on the class assert "_doubled_var" not in AliasState.__dict__ - assert AliasState(_reflex_internal_init=True).doubled == 0 + assert AliasState(_reflex_internal_init=True).doubled == 0 # pyright: ignore[reportCallIssue] assert str(Var.create(AliasState.doubled)) == str(Var.create(AliasState.count * 3)) @@ -173,7 +173,7 @@ def _maybe_var(cls) -> Var[int] | None: return None assert NoFrontendState.maybe is None - assert NoFrontendState(_reflex_internal_init=True).maybe == 0 + assert NoFrontendState(_reflex_internal_init=True).maybe == 0 # pyright: ignore[reportCallIssue] def test_hybrid_property_none_on_object_var_raises(): @@ -214,11 +214,11 @@ def value(self) -> str: return self._value @value.setter - def value(self, new: str) -> None: + def _value_setter(self, new: str) -> None: self._value = new - @value.deleter - def value(self) -> None: + @_value_setter.deleter + def _value_deleter(self) -> None: seen.append("deleted") holder = Holder() @@ -244,7 +244,7 @@ def doubled(self) -> int: # pyright: ignore[reportRedeclaration] def doubled(cls) -> Var[int]: return cls.count * 4 # pyright: ignore[reportReturnType] - assert ClassmethodVarState(_reflex_internal_init=True).doubled == 0 + assert ClassmethodVarState(_reflex_internal_init=True).doubled == 0 # pyright: ignore[reportCallIssue] assert str(Var.create(ClassmethodVarState.doubled)) == str( Var.create(ClassmethodVarState.count * 4) ) diff --git a/tests/units/vars/test_object.py b/tests/units/vars/test_object.py index 41e7ae8358a..835bf9fbc72 100644 --- a/tests/units/vars/test_object.py +++ b/tests/units/vars/test_object.py @@ -53,7 +53,7 @@ def is_nonzero(self) -> bool: return self.quantity != 0 @is_nonzero.var - def is_nonzero(cls) -> Var[bool]: + def _is_nonzero_var(cls) -> Var[bool]: """The frontend var for ``is_nonzero`` (deliberately distinct from the getter). Returns: From e9d7ecb37b8734f38cf92aad92284b95c1cf45ab Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sun, 26 Jul 2026 16:41:04 +0200 Subject: [PATCH 3/3] wip --- packages/reflex-base/news/6812.feature.md | 2 +- .../src/reflex_base/vars/hybrid_property.py | 103 +++++++++++++++++- tests/units/vars/test_hybrid_property.py | 37 +++++++ 3 files changed, 139 insertions(+), 3 deletions(-) diff --git a/packages/reflex-base/news/6812.feature.md b/packages/reflex-base/news/6812.feature.md index 2aab0c0800b..31903e7263f 100644 --- a/packages/reflex-base/news/6812.feature.md +++ b/packages/reflex-base/news/6812.feature.md @@ -1 +1 @@ -`hybrid_property` is no longer a `property` subclass, so class-level access now resolves to the frontend var's type instead of the descriptor. Its var function may be declared a `classmethod`, may return `None` to declare that the property has no frontend value on a class, and — like `getter`, `setter` and `deleter` — may be defined under a name of its own instead of shadowing the property's declaration. +`hybrid_property` is no longer a `property` subclass, so class-level access now resolves to the frontend var's type instead of the descriptor: without a var function it follows the var equivalent of the getter's return type, and a var function may declare a type of its own. The var function may be a `classmethod`, may return `None` to declare that the property has no frontend value on a class, and — like `getter`, `setter` and `deleter` — may be defined under a name of its own instead of shadowing the property's declaration. diff --git a/packages/reflex-base/src/reflex_base/vars/hybrid_property.py b/packages/reflex-base/src/reflex_base/vars/hybrid_property.py index 892c1d2c52a..734ed610008 100644 --- a/packages/reflex-base/src/reflex_base/vars/hybrid_property.py +++ b/packages/reflex-base/src/reflex_base/vars/hybrid_property.py @@ -2,8 +2,8 @@ from __future__ import annotations -from collections.abc import Callable -from typing import Any, Generic, cast, overload +from collections.abc import Callable, Mapping, Sequence +from typing import TYPE_CHECKING, Any, Generic, cast, overload from typing_extensions import TypeVar @@ -11,8 +11,18 @@ from .base import Var +if TYPE_CHECKING: + from .number import BooleanVar, NumberVar + from .object import ObjectVar + from .sequence import ArrayVar, StringVar + _T = TypeVar("_T") _O = TypeVar("_O") +# the getter's return type, for resolving class-level access to a var type +_INT = TypeVar("_INT", bound=int) +_STR = TypeVar("_STR", bound=str) +_SEQUENCE = TypeVar("_SEQUENCE", bound=Sequence[Any]) +_MAPPING = TypeVar("_MAPPING", bound=Mapping[Any, Any]) # Without a var function the frontend value is whatever running the getter # against vars produces, and a var function may declare there is none. _V = TypeVar("_V", default=Var[Any] | None) @@ -71,6 +81,11 @@ class HybridProperty(Generic[_T, _O, _V]): var this descriptor returns there. `_T` is the value the getter returns on an instance, `_O` the class the property is defined on, and `_V` the frontend var returned when the property is accessed on the class. + + Without a var function, class-level access is typed as the var equivalent of + `_T`, which holds as long as the getter builds a Var when it runs against + vars. A getter that collapses them to a plain Python value (e.g. `str(...)`, + `len(...)`) needs a var function to say what the frontend value really is. """ def __init__( @@ -161,6 +176,90 @@ def _get_var(self, owner: Any) -> Var[Any] | None: # the getter runs against vars here, so it returns the frontend var return cast("Var[Any] | None", self.fget(owner)) + # Without an explicitly typed var function, class-level access resolves to the + # var equivalent of the getter's return type, the way `Field` does for base + # vars. The `_V` default in the `self` annotations keeps these from matching + # once a var function has declared a type of its own. + @overload + def __get__( + self: HybridProperty[bool, Any, Var[Any] | None], + instance: None, + owner: type, + /, + ) -> BooleanVar: ... + + @overload + def __get__( + self: HybridProperty[bool | None, Any, Var[Any] | None], + instance: None, + owner: type, + /, + ) -> BooleanVar | None: ... + + @overload + def __get__( + self: HybridProperty[_INT, Any, Var[Any] | None], + instance: None, + owner: type, + /, + ) -> NumberVar[_INT]: ... + + @overload + def __get__( + self: HybridProperty[_INT | None, Any, Var[Any] | None], + instance: None, + owner: type, + /, + ) -> NumberVar[_INT] | None: ... + + @overload + def __get__( + self: HybridProperty[_STR, Any, Var[Any] | None], + instance: None, + owner: type, + /, + ) -> StringVar: ... + + @overload + def __get__( + self: HybridProperty[_STR | None, Any, Var[Any] | None], + instance: None, + owner: type, + /, + ) -> StringVar | None: ... + + @overload + def __get__( + self: HybridProperty[_MAPPING, Any, Var[Any] | None], + instance: None, + owner: type, + /, + ) -> ObjectVar[_MAPPING]: ... + + @overload + def __get__( + self: HybridProperty[_MAPPING | None, Any, Var[Any] | None], + instance: None, + owner: type, + /, + ) -> ObjectVar[_MAPPING] | None: ... + + @overload + def __get__( + self: HybridProperty[_SEQUENCE, Any, Var[Any] | None], + instance: None, + owner: type, + /, + ) -> ArrayVar[_SEQUENCE]: ... + + @overload + def __get__( + self: HybridProperty[_SEQUENCE | None, Any, Var[Any] | None], + instance: None, + owner: type, + /, + ) -> ArrayVar[_SEQUENCE] | None: ... + @overload def __get__(self, instance: None, owner: type, /) -> _V: ... diff --git a/tests/units/vars/test_hybrid_property.py b/tests/units/vars/test_hybrid_property.py index c98f71aacfd..22fa680d9c6 100644 --- a/tests/units/vars/test_hybrid_property.py +++ b/tests/units/vars/test_hybrid_property.py @@ -248,3 +248,40 @@ def doubled(cls) -> Var[int]: assert str(Var.create(ClassmethodVarState.doubled)) == str( Var.create(ClassmethodVarState.count * 4) ) + + +def test_hybrid_property_class_access_var_type_follows_getter(): + """Class-level access resolves to the var equivalent of the getter's return type.""" + + class LadderState(rx.State): + count: int = 0 + names: list[str] = [] + + @hybrid_property + def positive(self) -> bool: + return self.count > 0 + + @hybrid_property + def doubled(self) -> int: + return self.count * 2 + + @hybrid_property + def upper_names(self) -> list[str]: + return [name.upper() for name in self.names] + + @upper_names.var + @classmethod + def _upper_names_var(cls) -> Var[list[str]]: + return cls.names # pyright: ignore[reportReturnType] + + # the operations these var types carry must be available on class access + assert isinstance(LadderState.positive & True, Var) + assert isinstance(LadderState.doubled + 1, Var) + assert isinstance(LadderState.upper_names.length(), Var) + # ... while the getter still serves the instance + state = LadderState(_reflex_internal_init=True) # pyright: ignore[reportCallIssue] + state.count = 2 + state.names = ["a"] + assert state.positive is True + assert state.doubled == 4 + assert state.upper_names == ["A"]