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..31903e7263f --- /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: 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/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..734ed610008 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,33 @@ """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 collections.abc import Callable -from typing import Any +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from typing import TYPE_CHECKING, 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 +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) +_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 +73,82 @@ 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. - # The optional var function for the property. - _var: Callable[[Any], Var] | None = None + 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 _get_var(self, owner: Any) -> Var: + 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. + + 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 _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 that rebinds under the property's name. + """ + new: _HybridPropertyBinding[_T, _O, _V] = _HybridPropertyBinding( + 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[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 +160,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 +171,101 @@ 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)) + + # 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: ... + + @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 +284,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._derive() + 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._derive() + 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._derive() + 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 +411,56 @@ 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 = self._derive() new._var = func return new +class _HybridPropertyBinding(HybridProperty[_T, _O, _V]): + """The descriptor the `getter`, `setter`, `deleter` and `var` decorators return. + + 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. + + 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:: + + @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 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. + """ + target = self._name + if target is None: + msg = ( + 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__) + 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/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 bbd72ade340..22fa680d9c6 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): @@ -109,3 +109,179 @@ 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 # pyright: ignore[reportCallIssue] + 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 # pyright: ignore[reportCallIssue] + + +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_setter(self, new: str) -> None: + self._value = new + + @_value_setter.deleter + def _value_deleter(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 # pyright: ignore[reportCallIssue] + 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"] 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: