add hybrid_property

This commit is contained in:
Benedikt Bartscher 2024-08-17 18:37:20 +02:00
parent 911c2af044
commit adfd79b46b
No known key found for this signature in database
4 changed files with 256 additions and 2 deletions

View File

@ -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 `<property object at 0x723b334e5940>`.
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 "<property object at 0x" in python_full_name.text
has_last_name = driver.find_element(By.ID, "has_last_name")
assert has_last_name.text == "has_last_name: yes"
set_last_name = driver.find_element(By.ID, "set_last_name")
# clear the input
set_last_name.send_keys(Keys.CONTROL + "a")
set_last_name.send_keys(Keys.DELETE)
assert (
hybrid_properties.poll_for_content(
has_last_name, exp_not_equal="has_last_name: yes"
)
== "has_last_name: no"
)
assert full_name.text == "full_name: John"
state = (await hybrid_properties.get_state(token)).substates[state_name]
assert state is not None
assert state.full_name == "John "
assert state.has_last_name == "no"

View File

@ -49,7 +49,7 @@ from reflex.base import Base
from reflex.utils import console from reflex.utils import console
if sys.version_info >= (3, 12): if sys.version_info >= (3, 12):
from typing import override from typing import override as override
else: else:
def override(func: Callable) -> Callable: def override(func: Callable) -> Callable:
@ -64,6 +64,11 @@ else:
return func return func
if sys.version_info >= (3, 11):
from typing import Self as Self
else:
Self = None
# Potential GenericAlias types for isinstance checks. # Potential GenericAlias types for isinstance checks.
GenericAliasTypes = [_GenericAlias] GenericAliasTypes = [_GenericAlias]

View File

@ -51,7 +51,7 @@ from reflex.utils.imports import (
ParsedImportDict, ParsedImportDict,
parse_imports, parse_imports,
) )
from reflex.utils.types import override from reflex.utils.types import Self, override
if TYPE_CHECKING: if TYPE_CHECKING:
from reflex.state import BaseState from reflex.state import BaseState
@ -2546,6 +2546,51 @@ def computed_var(
# Partial function of computed_var with cache=True # Partial function of computed_var with cache=True
cached_var = functools.partial(computed_var, cache=True, _deprecated_cached_var=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): class CallableVar(BaseVar):
"""Decorate a Var-returning function to act as both a Var and a function. """Decorate a Var-returning function to act as both a Var and a function.

View File

@ -30,6 +30,7 @@ from reflex.utils import console as console
from reflex.utils import format as format from reflex.utils import format as format
from reflex.utils import types as types from reflex.utils import types as types
from reflex.utils.imports import ImmutableParsedImportDict, ImportDict, ParsedImportDict from reflex.utils.imports import ImmutableParsedImportDict, ImportDict, ParsedImportDict
from reflex.utils.types import Self, override
USED_VARIABLES: Incomplete USED_VARIABLES: Incomplete
@ -211,6 +212,16 @@ def cached_var(
@overload @overload
def cached_var(fget: Callable[[Any], Any]) -> ComputedVar: ... 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): class CallableVar(BaseVar):
def __init__(self, fn: Callable[..., BaseVar]): ... def __init__(self, fn: Callable[..., BaseVar]): ...
def __call__(self, *args, **kwargs) -> BaseVar: ... def __call__(self, *args, **kwargs) -> BaseVar: ...