From adfd79b46bfb3849bb5e0bc5240bc986b45ed323 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Sat, 17 Aug 2024 18:37:20 +0200 Subject: [PATCH 1/6] add hybrid_property --- integration/test_hybrid_properties.py | 193 ++++++++++++++++++++++++++ reflex/utils/types.py | 7 +- reflex/vars.py | 47 ++++++- reflex/vars.pyi | 11 ++ 4 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 integration/test_hybrid_properties.py diff --git a/integration/test_hybrid_properties.py b/integration/test_hybrid_properties.py new file mode 100644 index 000000000..85fd6037f --- /dev/null +++ b/integration/test_hybrid_properties.py @@ -0,0 +1,193 @@ +"""Test hybrid properties.""" + +from __future__ import annotations + +from typing import Generator + +import pytest +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys + +from reflex.testing import DEFAULT_TIMEOUT, AppHarness, WebDriver + + +def HybridProperties(): + """Test app for hybrid properties.""" + import reflex as rx + from reflex.vars import Var, hybrid_property + + class State(rx.State): + first_name: str = "John" + last_name: str = "Doe" + + @property + def python_full_name(self) -> str: + """A normal python property to showcase the current behavior. This renders to smth like ``. + + Returns: + str: The full name of the person. + """ + return f"{self.first_name} {self.last_name}" + + @hybrid_property + def full_name(self) -> str: + """A simple hybrid property which uses the same code for both frontend and backend. + + Returns: + str: The full name of the person. + """ + return f"{self.first_name} {self.last_name}" + + @hybrid_property + def has_last_name(self) -> str: + """A more complex hybrid property which uses different code for frontend and backend. + + Returns: + str: "yes" if the person has a last name, "no" otherwise. + """ + return "yes" if self.last_name else "no" + + @has_last_name.var + def has_last_name(cls) -> Var[str]: + """The frontend code for the `has_last_name` hybrid property. + + Returns: + Var[str]: The value of the hybrid property. + """ + return rx.cond(cls.last_name, "yes", "no") + + def index() -> rx.Component: + return rx.center( + rx.vstack( + rx.input( + id="token", + value=State.router.session.client_token, + is_read_only=True, + ), + rx.text( + f"python_full_name: {State.python_full_name}", id="python_full_name" + ), + rx.text(f"full_name: {State.full_name}", id="full_name"), + rx.text(f"has_last_name: {State.has_last_name}", id="has_last_name"), + rx.input( + value=State.last_name, + on_change=State.set_last_name, # type: ignore + id="set_last_name", + ), + ), + ) + + app = rx.App() + app.add_page(index) + + +@pytest.fixture(scope="module") +def hybrid_properties( + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[AppHarness, None, None]: + """Start HybridProperties app at tmp_path via AppHarness. + + Args: + tmp_path_factory: pytest tmp_path_factory fixture + + Yields: + running AppHarness instance + """ + with AppHarness.create( + root=tmp_path_factory.mktemp(f"hybrid_properties"), + app_source=HybridProperties, # type: ignore + ) as harness: + yield harness + + +@pytest.fixture +def driver(hybrid_properties: AppHarness) -> Generator[WebDriver, None, None]: + """Get an instance of the browser open to the hybrid_properties app. + + Args: + hybrid_properties: harness for HybridProperties app + + Yields: + WebDriver instance. + """ + assert hybrid_properties.app_instance is not None, "app is not running" + driver = hybrid_properties.frontend() + try: + yield driver + finally: + driver.quit() + + +@pytest.fixture() +def token(hybrid_properties: AppHarness, driver: WebDriver) -> str: + """Get a function that returns the active token. + + Args: + hybrid_properties: harness for HybridProperties app. + driver: WebDriver instance. + + Returns: + The token for the connected client + """ + assert hybrid_properties.app_instance is not None + token_input = driver.find_element(By.ID, "token") + assert token_input + + # wait for the backend connection to send the token + token = hybrid_properties.poll_for_value(token_input, timeout=DEFAULT_TIMEOUT * 2) + assert token is not None + + return token + + +@pytest.mark.asyncio +async def test_hybrid_properties( + hybrid_properties: AppHarness, + driver: WebDriver, + token: str, +): + """Test that hybrid properties are working as expected. + + Args: + hybrid_properties: harness for HybridProperties app. + driver: WebDriver instance. + token: The token for the connected client. + """ + assert hybrid_properties.app_instance is not None + + state_name = hybrid_properties.get_state_name("_state") + full_state_name = hybrid_properties.get_full_state_name(["_state"]) + token = f"{token}_{full_state_name}" + + state = (await hybrid_properties.get_state(token)).substates[state_name] + assert state is not None + assert state.full_name == "John Doe" + assert state.has_last_name == "yes" + + full_name = driver.find_element(By.ID, "full_name") + assert full_name.text == "full_name: John Doe" + + python_full_name = driver.find_element(By.ID, "python_full_name") + assert "= (3, 12): - from typing import override + from typing import override as override else: def override(func: Callable) -> Callable: @@ -64,6 +64,11 @@ else: return func +if sys.version_info >= (3, 11): + from typing import Self as Self +else: + Self = None + # Potential GenericAlias types for isinstance checks. GenericAliasTypes = [_GenericAlias] diff --git a/reflex/vars.py b/reflex/vars.py index ffaf16455..8f1f0186a 100644 --- a/reflex/vars.py +++ b/reflex/vars.py @@ -51,7 +51,7 @@ from reflex.utils.imports import ( ParsedImportDict, parse_imports, ) -from reflex.utils.types import override +from reflex.utils.types import Self, override if TYPE_CHECKING: from reflex.state import BaseState @@ -2546,6 +2546,51 @@ def computed_var( # Partial function of computed_var with cache=True cached_var = functools.partial(computed_var, cache=True, _deprecated_cached_var=True) +VAR_CALLABLE = Callable[[Any], Var] + + +class HybridProperty(property): + """A hybrid property that can also be used in frontend/as var.""" + + # The optional var function for the property. + _var: VAR_CALLABLE | 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. + + Args: + instance: The instance of the class accessing this property. + owner: The class that this descriptor is attached to. + + Returns: + The value of the property or a frontend Var. + """ + 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) + else: + # Call the property getter function if no custom var function is set + assert self.fget is not None + return self.fget(owner) + + def var(self, func: VAR_CALLABLE) -> Self: + """Set the (optional) var function for the property. + + Args: + func: The var function to set. + + Returns: + The property instance with the var function set. + """ + self._var = func + return self + + +hybrid_property = HybridProperty + class CallableVar(BaseVar): """Decorate a Var-returning function to act as both a Var and a function. diff --git a/reflex/vars.pyi b/reflex/vars.pyi index 47d433374..9b823f124 100644 --- a/reflex/vars.pyi +++ b/reflex/vars.pyi @@ -30,6 +30,7 @@ from reflex.utils import console as console from reflex.utils import format as format from reflex.utils import types as types from reflex.utils.imports import ImmutableParsedImportDict, ImportDict, ParsedImportDict +from reflex.utils.types import Self, override USED_VARIABLES: Incomplete @@ -211,6 +212,16 @@ def cached_var( @overload def cached_var(fget: Callable[[Any], Any]) -> ComputedVar: ... +VAR_CALLABLE = Callable[[Any], Var] + +class HybridProperty(property): + _var: VAR_CALLABLE | None = None + @override + def __get__(self, instance: Any, owner: type | None = None, /) -> Any: ... + def var(self, func: VAR_CALLABLE) -> Self: ... + +hybrid_property = HybridProperty + class CallableVar(BaseVar): def __init__(self, fn: Callable[..., BaseVar]): ... def __call__(self, *args, **kwargs) -> BaseVar: ... From 0f38cd6f60ea1d2d1125f4ac27722ce6ab68a8ba Mon Sep 17 00:00:00 2001 From: Khaleel Al-Adhami Date: Tue, 10 Sep 2024 12:15:26 -0700 Subject: [PATCH 2/6] remove reference to computed var --- reflex/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reflex/state.py b/reflex/state.py index c33072a4f..a1b5eac8c 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -739,7 +739,7 @@ class BaseState(Base, ABC, extra=pydantic.Extra.allow): NameError: When a computed var shadows another. """ for name, cv in cls.__dict__.items(): - if not isinstance(cv, (ComputedVar, ImmutableComputedVar)): + if not is_computed_var(cv): continue name = cv._var_name if name in cls.inherited_vars or name in cls.inherited_backend_vars: From 7d3071e747a990c677f4e0e09bfe62fe003d059e Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Tue, 10 Sep 2024 22:25:09 +0200 Subject: [PATCH 3/6] actually fix conflicts --- reflex/vars.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/reflex/vars.py b/reflex/vars.py index f57ba567b..e4ade596d 100644 --- a/reflex/vars.py +++ b/reflex/vars.py @@ -7,6 +7,7 @@ import dataclasses import random import re import string +from collections.abc import Callable from typing import ( TYPE_CHECKING, Any, @@ -32,7 +33,7 @@ from reflex.utils.imports import ( ParsedImportDict, parse_imports, ) -from reflex.utils.types import Self, get_origin, override +from reflex.utils.types import Self, override if TYPE_CHECKING: from reflex.ivars import ImmutableVar @@ -502,6 +503,9 @@ def get_uuid_string_var() -> ImmutableVar: ) +VAR_CALLABLE = Callable[[Any], Var] + + class HybridProperty(property): """A hybrid property that can also be used in frontend/as var.""" From 30ded81ef21fe43615d463c179349e2b6ce44f88 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Tue, 10 Sep 2024 22:29:02 +0200 Subject: [PATCH 4/6] use other callable --- reflex/vars.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reflex/vars.py b/reflex/vars.py index e4ade596d..05eb3f147 100644 --- a/reflex/vars.py +++ b/reflex/vars.py @@ -7,10 +7,10 @@ import dataclasses import random import re import string -from collections.abc import Callable from typing import ( TYPE_CHECKING, Any, + Callable, Dict, Iterable, Optional, From fdd30a670845d59362a5aa856c1df4d7988a6702 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Wed, 11 Sep 2024 18:38:05 +0200 Subject: [PATCH 5/6] better Self typing for old python versions --- reflex/utils/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reflex/utils/types.py b/reflex/utils/types.py index 10cbf096e..075f404e7 100644 --- a/reflex/utils/types.py +++ b/reflex/utils/types.py @@ -69,7 +69,7 @@ else: if sys.version_info >= (3, 11): from typing import Self as Self else: - Self = None + from typing_extensions import Self as Self # Potential GenericAlias types for isinstance checks. GenericAliasTypes = [_GenericAlias] From 1b3c82dcee62385a5b2b0aeb5755af085ded56b3 Mon Sep 17 00:00:00 2001 From: Benedikt Bartscher Date: Thu, 21 Nov 2024 00:55:39 +0100 Subject: [PATCH 6/6] move hybrid_property to experimental namespace --- reflex/experimental/__init__.py | 2 + reflex/experimental/hybrid_property.py | 49 +++++++++++++++++++++ reflex/vars/__init__.py | 1 - reflex/vars/base.py | 46 ------------------- tests/integration/test_hybrid_properties.py | 5 ++- 5 files changed, 54 insertions(+), 49 deletions(-) create mode 100644 reflex/experimental/hybrid_property.py diff --git a/reflex/experimental/__init__.py b/reflex/experimental/__init__.py index 164790fe5..c0fe2a8e0 100644 --- a/reflex/experimental/__init__.py +++ b/reflex/experimental/__init__.py @@ -11,6 +11,7 @@ from ..utils.console import warn from . import hooks as hooks from .assets import asset as asset from .client_state import ClientStateVar as ClientStateVar +from .hybrid_property import hybrid_property as hybrid_property from .layout import layout as layout from .misc import run_in_thread as run_in_thread @@ -69,4 +70,5 @@ _x = ExperimentalNamespace( PropsBase=PropsBase, run_in_thread=run_in_thread, code_block=code_block, + hybrid_property=hybrid_property, ) diff --git a/reflex/experimental/hybrid_property.py b/reflex/experimental/hybrid_property.py new file mode 100644 index 000000000..df2ea0eef --- /dev/null +++ b/reflex/experimental/hybrid_property.py @@ -0,0 +1,49 @@ +"""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 typing import Any, Callable + +from reflex.utils.types import Self, override +from reflex.vars.base import Var + + +class HybridProperty(property): + """A hybrid property that can also be used in frontend/as var.""" + + # 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. + + Args: + instance: The instance of the class accessing this property. + owner: The class that this descriptor is attached to. + + Returns: + The value of the property or a frontend Var. + """ + 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) + else: + # Call the property getter function if no custom var function is set + assert self.fget is not None + return self.fget(owner) + + def var(self, func: Callable[[Any], Var]) -> Self: + """Set the (optional) var function for the property. + + Args: + func: The var function to set. + + Returns: + The property instance with the var function set. + """ + self._var = func + return self + + +hybrid_property = HybridProperty diff --git a/reflex/vars/__init__.py b/reflex/vars/__init__.py index 28f4bf691..1a4cebe19 100644 --- a/reflex/vars/__init__.py +++ b/reflex/vars/__init__.py @@ -7,7 +7,6 @@ from .base import VarData as VarData from .base import field as field from .base import get_unique_variable_name as get_unique_variable_name from .base import get_uuid_string_var as get_uuid_string_var -from .base import hybrid_property as hybrid_property from .base import var_operation as var_operation from .base import var_operation_return as var_operation_return from .function import FunctionStringVar as FunctionStringVar diff --git a/reflex/vars/base.py b/reflex/vars/base.py index dd4343dfe..b06e7b7c9 100644 --- a/reflex/vars/base.py +++ b/reflex/vars/base.py @@ -2949,49 +2949,3 @@ def field(value: T) -> Field[T]: The Field. """ return value # type: ignore - - -VAR_CALLABLE = Callable[[Any], Var] - - -class HybridProperty(property): - """A hybrid property that can also be used in frontend/as var.""" - - # The optional var function for the property. - _var: VAR_CALLABLE | 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. - - Args: - instance: The instance of the class accessing this property. - owner: The class that this descriptor is attached to. - - Returns: - The value of the property or a frontend Var. - """ - 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) - else: - # Call the property getter function if no custom var function is set - assert self.fget is not None - return self.fget(owner) - - def var(self, func: VAR_CALLABLE) -> Self: - """Set the (optional) var function for the property. - - Args: - func: The var function to set. - - Returns: - The property instance with the var function set. - """ - self._var = func - return self - - -hybrid_property = HybridProperty diff --git a/tests/integration/test_hybrid_properties.py b/tests/integration/test_hybrid_properties.py index 85fd6037f..371ddbe18 100644 --- a/tests/integration/test_hybrid_properties.py +++ b/tests/integration/test_hybrid_properties.py @@ -14,7 +14,8 @@ from reflex.testing import DEFAULT_TIMEOUT, AppHarness, WebDriver def HybridProperties(): """Test app for hybrid properties.""" import reflex as rx - from reflex.vars import Var, hybrid_property + from reflex.experimental import hybrid_property + from reflex.vars import Var class State(rx.State): first_name: str = "John" @@ -71,7 +72,7 @@ def HybridProperties(): rx.text(f"has_last_name: {State.has_last_name}", id="has_last_name"), rx.input( value=State.last_name, - on_change=State.set_last_name, # type: ignore + on_change=State.setvar("last_name"), id="set_last_name", ), ),