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
if sys.version_info >= (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]

View File

@ -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.

View File

@ -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: ...