-
Notifications
You must be signed in to change notification settings - Fork 1.7k
add hybrid_property #3806
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
add hybrid_property #3806
Changes from 19 commits
adfd79b
acdd88e
f4f44f8
2ff47e1
58399e5
dbec527
0f38cd6
f4a45fd
fc58c38
7d3071e
30ded81
a541107
fdd30a6
c852767
1b3c82d
e64783b
0594a8a
8290c86
5fc942e
69d57af
34e67ed
0ac6e3e
21fa0bc
1e76998
cf67b87
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 collections.abc import Callable | ||
| from typing import Any | ||
|
|
||
| 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) | ||
| # 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,195 @@ | ||
| """Test hybrid properties.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from collections.abc 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.experimental import hybrid_property | ||
| from reflex.vars import Var | ||
|
|
||
| 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.el.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.el.input( | ||
| value=State.last_name, | ||
| on_change=State.setvar("last_name"), | ||
| 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("hybrid_properties"), | ||
| app_source=HybridProperties, | ||
| ) 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 = AppHarness.poll_for_or_raise_timeout( | ||
| lambda: driver.find_element(By.ID, "token") | ||
| ) | ||
|
|
||
| # 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}" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: Token is being reassigned here, shadowing the fixture parameter. Consider using a different variable name like |
||
|
|
||
| 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" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logic: The assertion expects 'John ' (with trailing space) but this seems inconsistent with the frontend assertion on line 190 that expects 'John'. This could indicate a bug in the hybrid property implementation or test logic. |
||
|
|
||
| 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" | ||
Uh oh!
There was an error while loading. Please reload this page.