-
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
Merged
+362
−5
Merged
add hybrid_property #3806
Changes from all commits
Commits
Show all changes
37 commits
Select commit
Hold shift + click to select a range
adfd79b
add hybrid_property
benedikt-bartscher acdd88e
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher f4f44f8
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher 2ff47e1
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher 58399e5
Merge remote-tracking branch 'origin/main' into hybrid-properties
benedikt-bartscher dbec527
fix merge conflicts
benedikt-bartscher 0f38cd6
remove reference to computed var
adhami3310 f4a45fd
fix conflicts
benedikt-bartscher fc58c38
Merge remote-tracking branch 'upstream/fix-reference-to-old-computed-…
benedikt-bartscher 7d3071e
actually fix conflicts
benedikt-bartscher 30ded81
use other callable
benedikt-bartscher a541107
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher fdd30a6
better Self typing for old python versions
benedikt-bartscher c852767
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher 1b3c82d
move hybrid_property to experimental namespace
benedikt-bartscher e64783b
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher 0594a8a
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher 8290c86
oups
benedikt-bartscher 5fc942e
fix: adjust test for hybrid properties to upstream changes
benedikt-bartscher 69d57af
Update reflex/experimental/hybrid_property.py
benedikt-bartscher 34e67ed
ruffing
benedikt-bartscher 0ac6e3e
fix darglint, document exception for hybrid_property
benedikt-bartscher 21fa0bc
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher 1e76998
Merge remote-tracking branch 'upstream' into hybrid-properties
benedikt-bartscher cf67b87
fix pyright
benedikt-bartscher 0a53ae5
Merge remote-tracking branch 'origin/main' into hybrid-properties
masenf a48b0f3
Merge remote-tracking branch 'upstream' into hybrid-properties
benedikt-bartscher a3bc40a
Merge remote-tracking branch 'origin/main' into hybrid-properties
benedikt-bartscher 9e4ce1b
adjust tests
benedikt-bartscher fc44d54
dont use setvar
benedikt-bartscher 7c6def0
fix: add support for tracking dependencies through hybrid properties
benedikt-bartscher ea48aa4
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher 6b1032b
Merge remote-tracking branch 'upstream/main' into hybrid-properties
benedikt-bartscher d45750b
Merge branch 'main' into hybrid-properties
masenf 7c9da05
Fix mismerge of old rx._x.memo
masenf 8b98a85
add news fragment
masenf e69fb3d
add news fragment for reflex-base
masenf File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Added `rx._x.hybrid_property`, a property decorator usable on State classes that works like a normal Python property for backend access while also rendering on the frontend at class level. Use the same method for both, or register a separate frontend implementation with `@<name>.var`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Dependency tracking now follows through hybrid properties, so computed vars that read a `hybrid_property` correctly recompute when the underlying state vars change. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| """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. | ||
|
|
||
| Raises: | ||
| AttributeError: If the property has no getter function and no var function is set. | ||
| """ | ||
| 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 | ||
| if self.fget is None: | ||
| msg = "HybridProperty has no getter function" | ||
| raise AttributeError(msg) | ||
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,231 @@ | ||
| """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") | ||
|
|
||
| @rx.var | ||
| def full_name_backend(self) -> str: | ||
| """Expose the backend value of the `full_name` hybrid property. | ||
|
|
||
| Returns: | ||
| str: The full name as evaluated by the backend property getter. | ||
| """ | ||
| return self.full_name | ||
|
|
||
| @rx.var | ||
| def has_last_name_backend(self) -> str: | ||
| """Expose the backend value of the `has_last_name` hybrid property. | ||
|
|
||
| Returns: | ||
| str: The has_last_name value as evaluated by the backend property getter. | ||
| """ | ||
| return self.has_last_name | ||
|
|
||
| @rx.event | ||
| def update_last_name(self, value: str): | ||
| """Update the last_name field. | ||
|
|
||
| Args: | ||
| value: The new last name value. | ||
| """ | ||
| self.last_name = value | ||
|
|
||
| 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"full_name_backend: {State.full_name_backend}", | ||
| id="full_name_backend", | ||
| ), | ||
| rx.text(f"has_last_name: {State.has_last_name}", id="has_last_name"), | ||
| rx.text( | ||
| f"has_last_name_backend: {State.has_last_name_backend}", | ||
| id="has_last_name_backend", | ||
| ), | ||
| rx.el.input( | ||
| value=State.last_name, | ||
| on_change=State.update_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 | ||
|
|
||
|
|
||
| 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 (used to wait for backend connection). | ||
| """ | ||
| assert hybrid_properties.app_instance is not None | ||
| assert token | ||
|
|
||
| full_name = driver.find_element(By.ID, "full_name") | ||
| assert full_name.text == "full_name: John Doe" | ||
|
|
||
| full_name_backend = driver.find_element(By.ID, "full_name_backend") | ||
| assert full_name_backend.text == "full_name_backend: 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" | ||
|
|
||
| has_last_name_backend = driver.find_element(By.ID, "has_last_name_backend") | ||
| assert has_last_name_backend.text == "has_last_name_backend: 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 ( | ||
| hybrid_properties.poll_for_content( | ||
| has_last_name_backend, exp_not_equal="has_last_name_backend: yes" | ||
| ) | ||
| == "has_last_name_backend: no" | ||
| ) | ||
|
|
||
| assert full_name.text == "full_name: John" | ||
| # Use textContent to preserve trailing whitespace (Selenium's .text strips it), | ||
| # since the backend f-string yields "John " (with trailing space) when last_name is empty. | ||
| assert full_name_backend.get_attribute("textContent") == "full_name_backend: John " | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.