Add pc.list
for mutation detection (#339)
This commit is contained in:
parent
5aae6a122d
commit
d5a76f103a
@ -13,7 +13,7 @@ from redis import Redis
|
|||||||
from pynecone import constants, utils
|
from pynecone import constants, utils
|
||||||
from pynecone.base import Base
|
from pynecone.base import Base
|
||||||
from pynecone.event import Event, EventHandler, window_alert
|
from pynecone.event import Event, EventHandler, window_alert
|
||||||
from pynecone.var import BaseVar, ComputedVar, Var
|
from pynecone.var import BaseVar, ComputedVar, PCList, Var
|
||||||
|
|
||||||
Delta = Dict[str, Any]
|
Delta = Dict[str, Any]
|
||||||
|
|
||||||
@ -61,6 +61,40 @@ class State(Base, ABC):
|
|||||||
for substate in self.get_substates():
|
for substate in self.get_substates():
|
||||||
self.substates[substate.get_name()] = substate().set(parent_state=self)
|
self.substates[substate.get_name()] = substate().set(parent_state=self)
|
||||||
|
|
||||||
|
self._init_mutable_fields()
|
||||||
|
|
||||||
|
def _init_mutable_fields(self):
|
||||||
|
"""Initialize mutable fields.
|
||||||
|
|
||||||
|
So that mutation to them can be detected by the app:
|
||||||
|
* list
|
||||||
|
"""
|
||||||
|
for field in self.base_vars.values():
|
||||||
|
value = getattr(self, field.name)
|
||||||
|
|
||||||
|
value_in_pc_data = _convert_mutable_datatypes(
|
||||||
|
value, self._reassign_field, field.name
|
||||||
|
)
|
||||||
|
|
||||||
|
if utils._issubclass(field.type_, List):
|
||||||
|
setattr(self, field.name, value_in_pc_data)
|
||||||
|
|
||||||
|
self.clean()
|
||||||
|
|
||||||
|
def _reassign_field(self, field_name: str):
|
||||||
|
"""Reassign the given field.
|
||||||
|
|
||||||
|
Primarily for mutation in fields of mutable data types.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
field_name (str): The name of the field we want to reassign
|
||||||
|
"""
|
||||||
|
setattr(
|
||||||
|
self,
|
||||||
|
field_name,
|
||||||
|
getattr(self, field_name),
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""Get the string representation of the state.
|
"""Get the string representation of the state.
|
||||||
|
|
||||||
@ -578,3 +612,38 @@ class StateManager(Base):
|
|||||||
if self.redis is None:
|
if self.redis is None:
|
||||||
return
|
return
|
||||||
self.redis.set(token, pickle.dumps(state), ex=self.token_expiration)
|
self.redis.set(token, pickle.dumps(state), ex=self.token_expiration)
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_mutable_datatypes(
|
||||||
|
field_value: Any, reassign_field: Callable, field_name: str
|
||||||
|
) -> Any:
|
||||||
|
"""Recursively convert mutable data to the Pc data types.
|
||||||
|
|
||||||
|
Note: right now only list & dict would be handled recursively.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
field_value: The target field_value.
|
||||||
|
reassign_field:
|
||||||
|
The function to reassign the field in the parent state.
|
||||||
|
field_name: the name of the field in the parent state
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The converted field_value
|
||||||
|
"""
|
||||||
|
if isinstance(field_value, list):
|
||||||
|
for index in range(len(field_value)):
|
||||||
|
field_value[index] = _convert_mutable_datatypes(
|
||||||
|
field_value[index], reassign_field, field_name
|
||||||
|
)
|
||||||
|
|
||||||
|
field_value = PCList(
|
||||||
|
field_value, reassign_field=reassign_field, field_name=field_name
|
||||||
|
)
|
||||||
|
|
||||||
|
elif isinstance(field_value, dict):
|
||||||
|
for key, value in field_value.items():
|
||||||
|
field_value[key] = _convert_mutable_datatypes(
|
||||||
|
value, reassign_field, field_name
|
||||||
|
)
|
||||||
|
|
||||||
|
return field_value
|
||||||
|
@ -748,3 +748,96 @@ class ComputedVar(property, Var):
|
|||||||
if "return" in self.fget.__annotations__:
|
if "return" in self.fget.__annotations__:
|
||||||
return self.fget.__annotations__["return"]
|
return self.fget.__annotations__["return"]
|
||||||
return Any
|
return Any
|
||||||
|
|
||||||
|
|
||||||
|
class PCList(list):
|
||||||
|
"""A custom list that pynecone can detect its mutation."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
original_list: List,
|
||||||
|
reassign_field: Callable = lambda _field_name: None,
|
||||||
|
field_name: str = "",
|
||||||
|
):
|
||||||
|
"""Initialize PCList.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
original_list (List): The original list
|
||||||
|
reassign_field (Callable):
|
||||||
|
The method in the parent state to reassign the field.
|
||||||
|
Default to be a no-op function
|
||||||
|
field_name (str): the name of field in the parent state
|
||||||
|
"""
|
||||||
|
self._reassign_field = lambda: reassign_field(field_name)
|
||||||
|
|
||||||
|
super().__init__(original_list)
|
||||||
|
|
||||||
|
def append(self, *args, **kargs):
|
||||||
|
"""Append.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: The args passed.
|
||||||
|
kargs: The kwargs passed.
|
||||||
|
"""
|
||||||
|
super().append(*args, **kargs)
|
||||||
|
self._reassign_field()
|
||||||
|
|
||||||
|
def __setitem__(self, *args, **kargs):
|
||||||
|
"""Set item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: The args passed.
|
||||||
|
kargs: The kwargs passed.
|
||||||
|
"""
|
||||||
|
super().__setitem__(*args, **kargs)
|
||||||
|
self._reassign_field()
|
||||||
|
|
||||||
|
def __delitem__(self, *args, **kargs):
|
||||||
|
"""Delete item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: The args passed.
|
||||||
|
kargs: The kwargs passed.
|
||||||
|
"""
|
||||||
|
super().__delitem__(*args, **kargs)
|
||||||
|
self._reassign_field()
|
||||||
|
|
||||||
|
def clear(self, *args, **kargs):
|
||||||
|
"""Remove all item from the list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: The args passed.
|
||||||
|
kargs: The kwargs passed.
|
||||||
|
"""
|
||||||
|
super().clear(*args, **kargs)
|
||||||
|
self._reassign_field()
|
||||||
|
|
||||||
|
def extend(self, *args, **kargs):
|
||||||
|
"""Add all item of a list to the end of the list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: The args passed.
|
||||||
|
kargs: The kwargs passed.
|
||||||
|
"""
|
||||||
|
super().extend(*args, **kargs)
|
||||||
|
self._reassign_field()
|
||||||
|
|
||||||
|
def pop(self, *args, **kargs):
|
||||||
|
"""Remove an element.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: The args passed.
|
||||||
|
kargs: The kwargs passed.
|
||||||
|
"""
|
||||||
|
super().pop(*args, **kargs)
|
||||||
|
self._reassign_field()
|
||||||
|
|
||||||
|
def remove(self, *args, **kargs):
|
||||||
|
"""Remove an element.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: The args passed.
|
||||||
|
kargs: The kwargs passed.
|
||||||
|
"""
|
||||||
|
super().remove(*args, **kargs)
|
||||||
|
self._reassign_field()
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
from typing import Type
|
from typing import List, Tuple, Type
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pynecone.app import App, DefaultState
|
from pynecone.app import App, DefaultState
|
||||||
from pynecone.components import Box
|
from pynecone.components import Box
|
||||||
|
from pynecone.event import Event
|
||||||
from pynecone.middleware import HydrateMiddleware
|
from pynecone.middleware import HydrateMiddleware
|
||||||
from pynecone.state import State
|
from pynecone.state import State
|
||||||
from pynecone.style import Style
|
from pynecone.style import Style
|
||||||
@ -156,3 +157,184 @@ def test_set_and_get_state(TestState: Type[State]):
|
|||||||
state2 = app.state_manager.get_state(token2)
|
state2 = app.state_manager.get_state(token2)
|
||||||
assert state1.var == 1 # type: ignore
|
assert state1.var == 1 # type: ignore
|
||||||
assert state2.var == 2 # type: ignore
|
assert state2.var == 2 # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def list_mutation_state():
|
||||||
|
"""A fixture to create a state with list mutation features.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A state with list mutation features.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class TestState(State):
|
||||||
|
"""The test state."""
|
||||||
|
|
||||||
|
# plain list
|
||||||
|
plain_friends = ["Tommy"]
|
||||||
|
|
||||||
|
def make_friend(self):
|
||||||
|
self.plain_friends.append("another-fd")
|
||||||
|
|
||||||
|
def change_first_friend(self):
|
||||||
|
self.plain_friends[0] = "Jenny"
|
||||||
|
|
||||||
|
def unfriend_all_friends(self):
|
||||||
|
self.plain_friends.clear()
|
||||||
|
|
||||||
|
def unfriend_first_friend(self):
|
||||||
|
del self.plain_friends[0]
|
||||||
|
|
||||||
|
def remove_last_friend(self):
|
||||||
|
self.plain_friends.pop()
|
||||||
|
|
||||||
|
def make_friends_with_colleagues(self):
|
||||||
|
colleagues = ["Peter", "Jimmy"]
|
||||||
|
self.plain_friends.extend(colleagues)
|
||||||
|
|
||||||
|
def remove_tommy(self):
|
||||||
|
self.plain_friends.remove("Tommy")
|
||||||
|
|
||||||
|
# list in dict
|
||||||
|
friends_in_dict = {"Tommy": ["Jenny"]}
|
||||||
|
|
||||||
|
def remove_jenny_from_tommy(self):
|
||||||
|
self.friends_in_dict["Tommy"].remove("Jenny")
|
||||||
|
|
||||||
|
def add_jimmy_to_tommy_friends(self):
|
||||||
|
self.friends_in_dict["Tommy"].append("Jimmy")
|
||||||
|
|
||||||
|
def tommy_has_no_fds(self):
|
||||||
|
self.friends_in_dict["Tommy"].clear()
|
||||||
|
|
||||||
|
# nested list
|
||||||
|
friends_in_nested_list = [["Tommy"], ["Jenny"]]
|
||||||
|
|
||||||
|
def remove_first_group(self):
|
||||||
|
self.friends_in_nested_list.pop(0)
|
||||||
|
|
||||||
|
def remove_first_person_from_first_group(self):
|
||||||
|
self.friends_in_nested_list[0].pop(0)
|
||||||
|
|
||||||
|
def add_jimmy_to_second_group(self):
|
||||||
|
self.friends_in_nested_list[1].append("Jimmy")
|
||||||
|
|
||||||
|
return TestState()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"event_tuples",
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"test_state.make_friend",
|
||||||
|
{"test_state": {"plain_friends": ["Tommy", "another-fd"]}},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test_state.change_first_friend",
|
||||||
|
{"test_state": {"plain_friends": ["Jenny", "another-fd"]}},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
id="append then __setitem__",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"test_state.unfriend_first_friend",
|
||||||
|
{"test_state": {"plain_friends": []}},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test_state.make_friend",
|
||||||
|
{"test_state": {"plain_friends": ["another-fd"]}},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
id="delitem then append",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"test_state.make_friends_with_colleagues",
|
||||||
|
{"test_state": {"plain_friends": ["Tommy", "Peter", "Jimmy"]}},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test_state.remove_tommy",
|
||||||
|
{"test_state": {"plain_friends": ["Peter", "Jimmy"]}},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test_state.remove_last_friend",
|
||||||
|
{"test_state": {"plain_friends": ["Peter"]}},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test_state.unfriend_all_friends",
|
||||||
|
{"test_state": {"plain_friends": []}},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
id="extend, remove, pop, clear",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"test_state.add_jimmy_to_second_group",
|
||||||
|
{
|
||||||
|
"test_state": {
|
||||||
|
"friends_in_nested_list": [["Tommy"], ["Jenny", "Jimmy"]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test_state.remove_first_person_from_first_group",
|
||||||
|
{
|
||||||
|
"test_state": {
|
||||||
|
"friends_in_nested_list": [[], ["Jenny", "Jimmy"]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test_state.remove_first_group",
|
||||||
|
{"test_state": {"friends_in_nested_list": [["Jenny", "Jimmy"]]}},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
id="nested list",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"test_state.add_jimmy_to_tommy_friends",
|
||||||
|
{"test_state": {"friends_in_dict": {"Tommy": ["Jenny", "Jimmy"]}}},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test_state.remove_jenny_from_tommy",
|
||||||
|
{"test_state": {"friends_in_dict": {"Tommy": ["Jimmy"]}}},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test_state.tommy_has_no_fds",
|
||||||
|
{"test_state": {"friends_in_dict": {"Tommy": []}}},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
id="list in dict",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_list_mutation_detection__plain_list(
|
||||||
|
event_tuples: List[Tuple[str, List[str]]], list_mutation_state: State
|
||||||
|
):
|
||||||
|
"""Test list mutation detection
|
||||||
|
when reassignment is not explicitly included in the logic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_tuples: From parametrization.
|
||||||
|
list_mutation_state: A state with list mutation features.
|
||||||
|
"""
|
||||||
|
for event_name, expected_delta in event_tuples:
|
||||||
|
result = await list_mutation_state.process(
|
||||||
|
Event(
|
||||||
|
token="fake-token",
|
||||||
|
name=event_name,
|
||||||
|
router_data={"pathname": "/", "query": {}},
|
||||||
|
payload={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.delta == expected_delta
|
||||||
|
Loading…
Reference in New Issue
Block a user