From 762e82b10ab00c50a6aafba66bc5d44a44ca0461 Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Wed, 24 Jul 2024 08:31:17 +0200 Subject: [PATCH 01/12] feat: trigger a calculated property on mutation * feat: implement subscribe_mutation --- src/writer/core.py | 59 +++++++++++++++- tests/backend/test_core.py | 135 +++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 1 deletion(-) diff --git a/src/writer/core.py b/src/writer/core.py index c565066f9..91b67c398 100644 --- a/src/writer/core.py +++ b/src/writer/core.py @@ -16,7 +16,7 @@ import traceback import urllib.request from abc import ABCMeta -from functools import wraps +from functools import partial, wraps from multiprocessing.process import BaseProcess from types import ModuleType from typing import ( @@ -107,6 +107,21 @@ class Config: mode: str = "run" logger: Optional[logging.Logger] = None +@dataclasses.dataclass +class MutationSubscription: + """ + Describes a subscription to a mutation. + + The path on which this subscription is subscribed and the handler + to execute when a mutation takes place on this path. + + >>> def myhandler(state): + >>> state["b"] = state["a"] + + >>> m = MutationSubscription(path="a.c", handler=myhandler) + """ + path: str # Path to subscribe + handler: Callable # Handler to execute when mutation happens class FileWrapper: @@ -327,6 +342,7 @@ class StateProxy: def __init__(self, raw_state: Dict = {}): self.state: Dict[str, Any] = {} + self.local_mutation_subscriptions: List[MutationSubscription] = [] self.initial_assignment = True self.mutated: Set[str] = set() self.ingest(raw_state) @@ -356,6 +372,11 @@ def __setitem__(self, key, raw_value) -> None: f"State keys must be strings. Received {str(key)} ({type(key)}).") self.state[key] = raw_value + + for local_mutation in self.local_mutation_subscriptions: + if local_mutation.path == key: + local_mutation.handler() + self._apply_raw(f"+{key}") def __delitem__(self, key: str) -> None: @@ -659,6 +680,42 @@ def _set_state_item(self, key: str, value: Any): self._state_proxy[key] = value + def subscribe_mutation(self, path: Union[str, List[str]], handler: Callable) -> None: + """ + Automatically triggers a handler when a mutation occurs in the state. + + >>> def _increment_counter(state): + >>> state_proxy['my_counter'] += 1 + >>> + >>> state = WriterState({'a': 1, 'c': {'a': 1, 'b': 3}, 'my_counter': 0}) + >>> state.subscribe_mutation('a', _increment_counter) + >>> state.subscribe_mutation('c.a', _increment_counter) + >>> state['a'] = 2 # will trigger _increment_counter + >>> state['a'] = 3 # will trigger _increment_counter + >>> state['c']['a'] = 2 # will trigger _increment_counter + + :param path: path of mutation to monitor + :param func: handler to call when the path is mutated + """ + if isinstance(path, str): + path_list = [path] + else: + path_list = path + + for p in path_list: + state_proxy = self._state_proxy + path_parts = p.split(".") + final_handler = partial(handler, self) + for i, path_part in enumerate(path_parts): + if i == len(path_parts) - 1: + local_mutation = MutationSubscription(path_parts[-1], final_handler) + state_proxy.local_mutation_subscriptions.append(local_mutation) + elif path_part in state_proxy: + state_proxy = state_proxy[path_part] + else: + raise ValueError("Mutation subscription failed - {p} not found in state") + + class WriterState(State): """ Root state. Comprises user configurable state and diff --git a/tests/backend/test_core.py b/tests/backend/test_core.py index b3bf26293..efdce395f 100644 --- a/tests/backend/test_core.py +++ b/tests/backend/test_core.py @@ -393,6 +393,141 @@ def test_remove_then_replace_nested_dictionary_should_trigger_mutation(self): } assert _state.to_dict() == {"nested": {"a": 1, "b": 2, "c": {"d": 3}}} + def test_subscribe_mutation_trigger_handler_when_mutation_happen(self): + """ + Tests that the handler that subscribes to a mutation fires when the mutation occurs. + """ + # Assign + def _increment_counter(state): + state['my_counter'] += 1 + + _state = WriterState({"a": 1, "my_counter": 0}) + _state.user_state.get_mutations_as_dict() + + # Acts + _state.subscribe_mutation('a', _increment_counter) + _state['a'] = 2 + + # Assert + assert _state['my_counter'] == 1 + + def test_subscribe_nested_mutation_should_trigger_handler_when_mutation_happen(self): + """ + Tests that a handler that subscribes to a nested mutation triggers when the mutation occurs. + """ + # Assign + def _increment_counter(state): + state['my_counter'] += 1 + + _state = WriterState({"a": 1, "c": {"a" : 1}, "my_counter": 0}) + _state.user_state.get_mutations_as_dict() + + # Acts + _state.subscribe_mutation('c.a', _increment_counter) + _state['c']['a'] = 2 + + # Assert + assert _state['my_counter'] == 1 + + def test_subscribe_2_mutation_should_trigger_handler_when_mutation_happen(self): + """ + Tests that it is possible to subscribe to 2 mutations simultaneously + """ + # Assign + def _increment_counter(state): + state['my_counter'] += 1 + + _state = WriterState({"a": 1, "c": {"a" : 1}, "my_counter": 0}) + _state.user_state.get_mutations_as_dict() + + # Acts + _state.subscribe_mutation(['a', 'c.a'], _increment_counter) + _state['c']['a'] = 2 + _state['a'] = 2 + + # Assert + assert _state['my_counter'] == 2 + mutations = _state.user_state.get_mutations_as_dict() + assert mutations['+my_counter'] == 2 + + def test_subscribe_mutation_should_trigger_cascading_handler(self): + """ + Tests that multiple handlers can be triggered in cascade if one of them modifies a value + that is listened to by another handler during a mutation. + """ + # Assign + def _increment_counter(state): + state['my_counter'] += 1 + + def _increment_counter2(state): + state['my_counter2'] += 1 + + _state = WriterState({"a": 1, "my_counter": 0, "my_counter2": 0}) + _state.user_state.get_mutations_as_dict() + + # Acts + _state.subscribe_mutation('a', _increment_counter) + _state.subscribe_mutation('my_counter', _increment_counter2) + _state['a'] = 2 + + # Assert + assert _state['my_counter'] == 1 + assert _state['my_counter2'] == 1 + mutations = _state.user_state.get_mutations_as_dict() + assert mutations['+my_counter'] == 1 + assert mutations['+my_counter2'] == 1 + + def test_subscribe_mutation_should_raise_error_on_infinite_cascading(self): + """ + Tests that an infinite recursive loop is detected and an error is raised if mutations cascade + + Python seems to raise a RecursionError by himself, so we just check that the error is raised + """ + try: + # Assign + def _increment_counter(state): + state['my_counter'] += 1 + + def _increment_counter2(state): + state['my_counter2'] += 1 + + _state = WriterState({"a": 1, "my_counter": 0, "my_counter2": 0}) + _state.user_state.get_mutations_as_dict() + + # Acts + _state.subscribe_mutation('a', _increment_counter) + _state.subscribe_mutation('my_counter', _increment_counter2) + _state.subscribe_mutation('my_counter2', _increment_counter) + _state['a'] = 2 + pytest.fail("Should raise an error") + except RecursionError: + assert True + + def test_subscribe_mutation_with_typed_state_should_manage_mutation(self): + """ + Tests that a mutation handler is triggered on a typed state and can use attributes directly. + """ + # Assign + class MyState(wf.WriterState): + counter: int + total: int + + def cumulative_sum(state: MyState): + state.total += state.counter + + initial_state = wf.init_state({ + "counter": 0, + "total": 0 + }, schema=MyState) + + initial_state.subscribe_mutation('counter', cumulative_sum) + + # Acts + initial_state['counter'] = 1 + initial_state['counter'] = 3 + + # Assert + assert initial_state['total'] == 4 class TestWriterState: From 1162809a333817596e86aa0e461e91930ee04ad2 Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Fri, 26 Jul 2024 08:38:28 +0200 Subject: [PATCH 02/12] feat: trigger a calculated property on mutation * feat: implement fixture to isolate test of global context --- tests/backend/fixtures/writer_fixtures.py | 40 +++++++++++++++++++++++ tests/backend/test_core.py | 35 ++++++++++---------- 2 files changed, 58 insertions(+), 17 deletions(-) create mode 100644 tests/backend/fixtures/writer_fixtures.py diff --git a/tests/backend/fixtures/writer_fixtures.py b/tests/backend/fixtures/writer_fixtures.py new file mode 100644 index 000000000..e6e9341ff --- /dev/null +++ b/tests/backend/fixtures/writer_fixtures.py @@ -0,0 +1,40 @@ +import contextlib +import copy + +from writer import WriterState, core, core_ui +from writer.core import Config, SessionManager + + +@contextlib.contextmanager +def new_app_context(): + """ + Creates a new application context for testing, independent of the global state. + + This fixture avoids conflicts between tests that use the same global state. + At the end of the context, the global state is restored to its original state. + + >>> with writer_fixtures.new_app_context(): + >>> initial_state = wf.init_state({ + >>> "counter": 0, + >>> "total": 0 + >>> }, schema=MyState) + """ + saved_context_vars = {} + core_context_vars = ['initial_state', 'base_component_tree', 'session_manager'] + core_config_vars = copy.deepcopy(core.Config) + + for var in core_context_vars: + saved_context_vars[var] = getattr(core, var) + + core.initial_state = WriterState() + core.base_component_tree = core_ui.build_base_component_tree() + core.session_manager = SessionManager() + Config.mode = "run" + Config.logger = None + + yield + + for var in core_context_vars: + setattr(core, var, saved_context_vars[var]) + + core.Config = core_config_vars diff --git a/tests/backend/test_core.py b/tests/backend/test_core.py index efdce395f..82ff37ead 100644 --- a/tests/backend/test_core.py +++ b/tests/backend/test_core.py @@ -30,7 +30,7 @@ from writer.core_ui import Component from writer.ss_types import WriterEvent -from backend.fixtures import core_ui_fixtures +from backend.fixtures import core_ui_fixtures, writer_fixtures from tests.backend import test_app_dir raw_state_dict = { @@ -507,27 +507,28 @@ def test_subscribe_mutation_with_typed_state_should_manage_mutation(self): """ Tests that a mutation handler is triggered on a typed state and can use attributes directly. """ - # Assign - class MyState(wf.WriterState): - counter: int - total: int + with writer_fixtures.new_app_context(): + # Assign + class MyState(wf.WriterState): + counter: int + total: int - def cumulative_sum(state: MyState): - state.total += state.counter + def cumulative_sum(state: MyState): + state.total += state.counter - initial_state = wf.init_state({ - "counter": 0, - "total": 0 - }, schema=MyState) + initial_state = wf.init_state({ + "counter": 0, + "total": 0 + }, schema=MyState) - initial_state.subscribe_mutation('counter', cumulative_sum) + initial_state.subscribe_mutation('counter', cumulative_sum) - # Acts - initial_state['counter'] = 1 - initial_state['counter'] = 3 + # Acts + initial_state['counter'] = 1 + initial_state['counter'] = 3 - # Assert - assert initial_state['total'] == 4 + # Assert + assert initial_state['total'] == 4 class TestWriterState: From 7596eea2689717972c22c8250dccf5840bd67841 Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Mon, 29 Jul 2024 11:37:43 +0200 Subject: [PATCH 03/12] feat: trigger a calculated property on mutation * feat: implement calculated properties --- src/writer/__init__.py | 4 ++ src/writer/core.py | 82 ++++++++++++++++++++++++++++++++++---- tests/backend/test_core.py | 62 ++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 8 deletions(-) diff --git a/src/writer/__init__.py b/src/writer/__init__.py index 1639e1974..255cb89da 100644 --- a/src/writer/__init__.py +++ b/src/writer/__init__.py @@ -17,6 +17,9 @@ session_manager, session_verifier, ) +from writer.core import ( + writerproperty as property, +) from writer.ui import WriterUIManager VERSION = importlib.metadata.version("writer") @@ -94,6 +97,7 @@ def init_state(raw_state: Dict[str, Any], schema: Optional[Type[S]] = None) -> U raise ValueError("Root schema must inherit from WriterState") _initial_state: S = new_initial_state(concrete_schema, raw_state) + return _initial_state diff --git a/src/writer/core.py b/src/writer/core.py index 91b67c398..a01185c29 100644 --- a/src/writer/core.py +++ b/src/writer/core.py @@ -4,6 +4,7 @@ import copy import dataclasses import datetime +import functools import inspect import io import json @@ -14,6 +15,7 @@ import secrets import time import traceback +import types import urllib.request from abc import ABCMeta from functools import partial, wraps @@ -76,7 +78,6 @@ def get_app_process() -> 'AppProcess': raise RuntimeError( "Failed to retrieve the AppProcess: running in wrong context") - def import_failure(rvalue: Any = None): """ This decorator captures the failure to load a volume and returns a value instead. @@ -511,7 +512,6 @@ def get_annotations(instance) -> Dict[str, Any]: ann = {} return ann - class StateMeta(type): """ Constructs a class at runtime that extends WriterState or State @@ -562,16 +562,15 @@ def bind_annotations_to_state_proxy(cls, klass): proxy = DictPropertyProxy("_state_proxy", key) setattr(klass, key, proxy) - class State(metaclass=StateMeta): - """ - `State` represents a state of the application. - """ - def __init__(self, raw_state: Dict[str, Any] = {}): self._state_proxy: StateProxy = StateProxy(raw_state) self.ingest(raw_state) + # Cette étape enregistre les propriétés associés à l'instance + for attribute in calculated_properties_per_state_type.get(self.__class__, []): + getattr(self, attribute) + def ingest(self, raw_state: Dict[str, Any]) -> None: """ hydrates a state from raw data by applying a schema when it is provided. @@ -680,7 +679,7 @@ def _set_state_item(self, key: str, value: Any): self._state_proxy[key] = value - def subscribe_mutation(self, path: Union[str, List[str]], handler: Callable) -> None: + def subscribe_mutation(self, path: Union[str, List[str]], handler: Callable[['State'], None]) -> None: """ Automatically triggers a handler when a mutation occurs in the state. @@ -2067,6 +2066,73 @@ def new_initial_state(klass: Type[S], raw_state: dict) -> S: return initial_state +""" +This variable contains the list of properties calculated for each class +that inherits from State. + +This mechanic allows Writer Framework to subscribe to mutations that trigger +these properties when loading an application. +""" +calculated_properties_per_state_type: Dict[Type[State], List[str]] = {} + +def writerproperty(path: Union[str, List[str]]): + """ + Mechanism for declaring a calculated property whenever an attribute changes + in the state of the Writer Framework application. + + >>> class MyState(wf.WriterState): + >>> counter: int + >>> + >>> @wf.property("counter") + >>> def double_counter(self): + >>> return self.counter * 2 + + This mechanism also supports a calculated property that depends on several dependencies. + + >>> class MyState(wf.WriterState): + >>> counterA: int + >>> counterB: int + >>> + >>> @wf.property(["counterA", "counterB"]) + >>> def counter_sum(self): + >>> return self.counterA + self.counterB + """ + + class Property(): + + def __init__(self, func): + self.func = func + self.instance = None + self.property_name = None + + def __call__(self, *args, **kwargs): + return self.func(*args, **kwargs) + + def __set_name__(self, owner: Type[State], name: str): + """ + Saves the calculated properties when loading the class. + """ + if owner not in calculated_properties_per_state_type: + calculated_properties_per_state_type[owner] = [] + + calculated_properties_per_state_type[owner].append(name) + self.property_name = name + + def __get__(self, instance: State, cls): + """ + This mechanism retrieves the property instance. + """ + property_name = self.property_name + if self.instance is None: + def calculated_property_handler(state): + instance._state_proxy[property_name] = self.func(state) + + instance.subscribe_mutation(path, calculated_property_handler) + self.instance = instance + + return self.func(instance) + + return Property def session_verifier(func: Callable) -> Callable: """ diff --git a/tests/backend/test_core.py b/tests/backend/test_core.py index 82ff37ead..0f432d320 100644 --- a/tests/backend/test_core.py +++ b/tests/backend/test_core.py @@ -1196,6 +1196,7 @@ def session_verifier_2(headers: Dict[str, str]) -> None: ) assert s_invalid is None + class TestEditableDataframe: def test_editable_dataframe_expose_pandas_dataframe_as_df_property(self) -> None: @@ -1562,3 +1563,64 @@ def myfunc(): return 2 assert myfunc() == 2 + +class TestCalculatedProperty(): + + def test_calculated_property_should_be_triggered_when_dependent_property_is_changing(self): + # Assign + class MyState(wf.WriterState): + counter: int + + @wf.property('counter') + def counter_str(self) -> str: + return str(self.counter) + + with writer_fixtures.new_app_context(): + state = wf.init_state({'counter': 0}, MyState) + state.user_state.get_mutations_as_dict() + + # Acts + state.counter = 2 + + # Assert + mutations = state.user_state.get_mutations_as_dict() + assert '+counter_str' in mutations + assert mutations['+counter_str'] == '2' + + def test_calculated_property_should_be_invoked_as_property(self): + # Assign + class MyState(wf.WriterState): + counter: int + + @wf.property('counter') + def counter_str(self) -> str: + return str(self.counter) + + with writer_fixtures.new_app_context(): + state = wf.init_state({'counter': 0}, MyState) + state.user_state.get_mutations_as_dict() + + # Assert + assert state.counter_str == '0' + + def test_calculated_property_should_be_triggered_when_one_dependent_property_is_changing(self): + # Assign + class MyState(wf.WriterState): + counterA: int + counterB: int + + @wf.property(['counterA', 'counterB']) + def counter_sum(self) -> int: + return self.counterA + self.counterB + + with writer_fixtures.new_app_context(): + state = wf.init_state({'counterA': 2, 'counterB': 4}, MyState) + state.user_state.get_mutations_as_dict() + + # Acts + state.counterA = 4 + + # Assert + mutations = state.user_state.get_mutations_as_dict() + assert '+counter_sum' in mutations + assert mutations['+counter_sum'] == 8 From ec6dc471c5b1065696e12c94990d91f06fe77087 Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Thu, 1 Aug 2024 06:56:23 +0200 Subject: [PATCH 04/12] feat: trigger a calculated property on mutation * docs: improve the documentation of application state --- docs/framework/application-state.mdx | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/framework/application-state.mdx b/docs/framework/application-state.mdx index 50b65383f..0b200f8f2 100644 --- a/docs/framework/application-state.mdx +++ b/docs/framework/application-state.mdx @@ -113,3 +113,33 @@ The front-end cannot directly display complex data types such as Pandas datafram Pandas dataframes are converted to JSON and can be used in _Dataframe_ components. + +## State schema + +State schema is a feature that allows you to define the structure of the state. +This is useful for ensuring that the state is always in the expected format. + +Schema allows you to use features like + +* typing checking with mypy / ruff +* autocomplete in IDEs +* declare dictionaries +* automatically calculate mutations on properties + +more into [Advanced > State schema](./state-schema) + +```python +import writer as wf + +class AppSchema(wf.WriterState): + counter: int + +initial_state = wf.init_state({ + "counter": 0 +}, schema=AppSchema) + +# Event handler +# It receives the session state as an argument and mutates it +def increment(state: AppSchema): + state.counter += 1 +``` From b6c2702c769ffa3332b9728445834cbe5c545fe6 Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Thu, 1 Aug 2024 07:40:18 +0200 Subject: [PATCH 05/12] feat: trigger a calculated property on mutation * fix: make it works on writer framework --- src/writer/core.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/writer/core.py b/src/writer/core.py index a01185c29..8c136f13b 100644 --- a/src/writer/core.py +++ b/src/writer/core.py @@ -709,6 +709,11 @@ def subscribe_mutation(self, path: Union[str, List[str]], handler: Callable[['St if i == len(path_parts) - 1: local_mutation = MutationSubscription(path_parts[-1], final_handler) state_proxy.local_mutation_subscriptions.append(local_mutation) + + # At startup, the application must be informed of the + # existing states. To cause this, we trigger manually + # the handler. + final_handler() elif path_part in state_proxy: state_proxy = state_proxy[path_part] else: @@ -2102,7 +2107,7 @@ class Property(): def __init__(self, func): self.func = func - self.instance = None + self.instances = set() self.property_name = None def __call__(self, *args, **kwargs): @@ -2123,12 +2128,12 @@ def __get__(self, instance: State, cls): This mechanism retrieves the property instance. """ property_name = self.property_name - if self.instance is None: + if instance not in self.instances: def calculated_property_handler(state): instance._state_proxy[property_name] = self.func(state) instance.subscribe_mutation(path, calculated_property_handler) - self.instance = instance + self.instances.add(instance) return self.func(instance) From 27fa261a6e7d0d9bbd7e74e8e80c6f4d9eaa6a18 Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Thu, 1 Aug 2024 07:42:42 +0200 Subject: [PATCH 06/12] feat: trigger a calculated property on mutation * docs: add documentation about calculated properties --- docs/framework/state-schema.mdx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/framework/state-schema.mdx b/docs/framework/state-schema.mdx index 4ee875ed2..7305d9b5f 100644 --- a/docs/framework/state-schema.mdx +++ b/docs/framework/state-schema.mdx @@ -62,6 +62,30 @@ initial_state = wf.init_state({ }, schema=AppSchema) ``` +## Calculated properties + +Calculated properties are updated automatically when a dependency changes. +They can be used to calculate values derived from application state. + +```python +class MyAppState(wf.State): + counter: List[int] + +class MyState(wf.WriterState): + counter: List[int] + + @wf.property(['counter', 'app.counter']) + def total_counter(self): + return sum(self.counter) + sum(self.app.counter) + +initial_state = wf.init_state({ + "counter": 0, + "my_app": { + "counter": 0 + } +}, schema=MyState) +``` + ## Multi-level dictionary Some components like _Vega Lite Chart_ require specifying a graph in the form of a multi-level dictionary. From 786271b03c812cf57f06cd27b4d29fe0a11b94b8 Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Thu, 1 Aug 2024 07:54:14 +0200 Subject: [PATCH 07/12] feat: trigger a calculated property on mutation * fix: trigger initial mutation on calculated properties --- src/writer/core.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/writer/core.py b/src/writer/core.py index 8c136f13b..7f3afd55c 100644 --- a/src/writer/core.py +++ b/src/writer/core.py @@ -679,7 +679,7 @@ def _set_state_item(self, key: str, value: Any): self._state_proxy[key] = value - def subscribe_mutation(self, path: Union[str, List[str]], handler: Callable[['State'], None]) -> None: + def subscribe_mutation(self, path: Union[str, List[str]], handler: Callable[['State'], None], initial_triggered: bool = False) -> None: """ Automatically triggers a handler when a mutation occurs in the state. @@ -713,7 +713,8 @@ def subscribe_mutation(self, path: Union[str, List[str]], handler: Callable[['St # At startup, the application must be informed of the # existing states. To cause this, we trigger manually # the handler. - final_handler() + if initial_triggered is True: + final_handler() elif path_part in state_proxy: state_proxy = state_proxy[path_part] else: @@ -2132,7 +2133,7 @@ def __get__(self, instance: State, cls): def calculated_property_handler(state): instance._state_proxy[property_name] = self.func(state) - instance.subscribe_mutation(path, calculated_property_handler) + instance.subscribe_mutation(path, calculated_property_handler, initial_triggered=True) self.instances.add(instance) return self.func(instance) From 1b56d1f37536bf33239453ee200eaa6278424aff Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Thu, 1 Aug 2024 07:58:57 +0200 Subject: [PATCH 08/12] feat: trigger a calculated property on mutation * docs: document mutation event --- docs/framework/event-handlers.mdx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/framework/event-handlers.mdx b/docs/framework/event-handlers.mdx index 6617c8357..c91cfadc4 100644 --- a/docs/framework/event-handlers.mdx +++ b/docs/framework/event-handlers.mdx @@ -136,6 +136,28 @@ def hande_click_cleaner(state): ``` +## Mutation event + +You can subscribe to mutations on a specific key in the state. +This is useful when you want to trigger a function every time a specific key is mutated. + +```python +import writer as wf + +def _increment_counter(state): + state['my_counter'] += 1 + +state = wf.init_state({"a": 1, "my_counter": 0}) +state.subscribe_mutation('a', _increment_counter) + +state['a'] = 2 # trigger _increment_counter mutation +``` + +```python +state.subscribe_mutation('a.b', _increment_counter) # subscribe to nested key +state.subscribe_mutation(['title', 'app.title'], _increment_counter) # subscribe to multiple keys +``` + ## Receiving a payload Several events include additional data, known as the event's payload. The event handler can receive that data using the `payload` argument. From 9dcbd48c97c4f8d6f0abd682467a679aea25c819 Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Thu, 1 Aug 2024 08:03:17 +0200 Subject: [PATCH 09/12] feat: trigger a calculated property on mutation * chore: review --- src/writer/core.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/writer/core.py b/src/writer/core.py index 7f3afd55c..3c422516e 100644 --- a/src/writer/core.py +++ b/src/writer/core.py @@ -18,7 +18,6 @@ import types import urllib.request from abc import ABCMeta -from functools import partial, wraps from multiprocessing.process import BaseProcess from types import ModuleType from typing import ( @@ -92,7 +91,7 @@ def import_failure(rvalue: Any = None): :param rvalue: the value to return """ def decorator(func): - @wraps(func) + @functools.wraps(func) def wrapper(*args, **kwargs): try: return func(*args, **kwargs) @@ -567,7 +566,7 @@ def __init__(self, raw_state: Dict[str, Any] = {}): self._state_proxy: StateProxy = StateProxy(raw_state) self.ingest(raw_state) - # Cette étape enregistre les propriétés associés à l'instance + # This step saves the properties associated with the instance for attribute in calculated_properties_per_state_type.get(self.__class__, []): getattr(self, attribute) @@ -704,7 +703,7 @@ def subscribe_mutation(self, path: Union[str, List[str]], handler: Callable[['St for p in path_list: state_proxy = self._state_proxy path_parts = p.split(".") - final_handler = partial(handler, self) + final_handler = functools.partial(handler, self) for i, path_part in enumerate(path_parts): if i == len(path_parts) - 1: local_mutation = MutationSubscription(path_parts[-1], final_handler) From 160b69e518bccbcbc6de9739711c89cc98cb738d Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Sun, 4 Aug 2024 09:23:49 +0200 Subject: [PATCH 10/12] feat: trigger a calculated property on mutation * feat: subscribe_mutation supports an event handler as a function --- src/writer/core.py | 99 ++++++++++++++++++++++++++++++-------- tests/backend/test_core.py | 21 ++++++++ 2 files changed, 100 insertions(+), 20 deletions(-) diff --git a/src/writer/core.py b/src/writer/core.py index 3c422516e..c8e4bcb7e 100644 --- a/src/writer/core.py +++ b/src/writer/core.py @@ -18,6 +18,7 @@ import types import urllib.request from abc import ABCMeta +from contextvars import ContextVar from multiprocessing.process import BaseProcess from types import ModuleType from typing import ( @@ -120,11 +121,56 @@ class MutationSubscription: >>> m = MutationSubscription(path="a.c", handler=myhandler) """ - path: str # Path to subscribe + path: str handler: Callable # Handler to execute when mutation happens -class FileWrapper: + def __post_init__(self): + if len(self.path) == 0: + raise ValueError("path cannot be empty.") + + path_parts = self.path.split(".") + for part in path_parts: + if len(part) == 0: + raise ValueError(f"path {self.path} cannot have empty parts.") + + @property + def local_path(self) -> str: + """ + Returns the last part of the key to monitor on the state + + >>> m = MutationSubscription(path="a.c", handler=myhandler) + >>> m.local_path + >>> "c" + """ + path_parts = self.path.split(".") + return path_parts[-1] + +class StateRecursionWatcher(): + limit = 128 + + def __init__(self): + self.counter_recursion = 0 + +_state_recursion_watcher = ContextVar("state_recursion_watcher", default=StateRecursionWatcher()) + +@contextlib.contextmanager +def state_recursion_new(key: str): + """ + Context manager to watch the state recursion on mutation subscriptions. + + The context throws a RecursionError exception if more than 128 cascading mutations + are performed on the same state + """ + recursion_watcher = _state_recursion_watcher.get() + try: + recursion_watcher.counter_recursion += 1 + if recursion_watcher.counter_recursion > recursion_watcher.limit: + raise RecursionError(f"State Recursion limit reached {recursion_watcher.limit}.") + yield + finally: + recursion_watcher.counter_recursion -= 1 +class FileWrapper: """ A wrapper for either a string pointing to a file or a file-like object with a read() method. Provides a method for retrieving the data as data URL. @@ -360,34 +406,44 @@ def ingest(self, raw_state: Dict) -> None: def items(self) -> Sequence[Tuple[str, Any]]: return cast(Sequence[Tuple[str, Any]], self.state.items()) - def get(self, key) -> Any: + def get(self, key: str) -> Any: return self.state.get(key) - def __getitem__(self, key) -> Any: + def __getitem__(self, key: str) -> Any: return self.state.get(key) - def __setitem__(self, key, raw_value) -> None: - if not isinstance(key, str): - raise ValueError( - f"State keys must be strings. Received {str(key)} ({type(key)}).") + def __setitem__(self, key: str, raw_value: Any) -> None: + with state_recursion_new(key): + if not isinstance(key, str): + raise ValueError( + f"State keys must be strings. Received {str(key)} ({type(key)}).") + previous_value = self.state.get(key) + self.state[key] = raw_value - self.state[key] = raw_value + for local_mutation in self.local_mutation_subscriptions: + if local_mutation.local_path == key: + from writer.ui import WriterUIManager - for local_mutation in self.local_mutation_subscriptions: - if local_mutation.path == key: - local_mutation.handler() + context = {"mutation": local_mutation.path} + payload = { + "mutation_previous_value": previous_value, + "mutation_value": raw_value + } + ui = WriterUIManager() + args = build_writer_func_arguments(local_mutation.handler, {"context": context, "payload": payload, "ui": ui}) + local_mutation.handler(*args) - self._apply_raw(f"+{key}") + self._apply_raw(f"+{key}") def __delitem__(self, key: str) -> None: if key in self.state: del self.state[key] self._apply_raw(f"-{key}") # Using "-" prefix to indicate deletion - def remove(self, key) -> None: + def remove(self, key: str) -> None: return self.__delitem__(key) - def _apply_raw(self, key) -> None: + def _apply_raw(self, key: str) -> None: self.mutated.add(key) def apply_mutation_marker(self, key: Optional[str] = None, recursive: bool = False) -> None: @@ -562,9 +618,12 @@ def bind_annotations_to_state_proxy(cls, klass): setattr(klass, key, proxy) class State(metaclass=StateMeta): - def __init__(self, raw_state: Dict[str, Any] = {}): - self._state_proxy: StateProxy = StateProxy(raw_state) - self.ingest(raw_state) + + def __init__(self, raw_state: Dict[str, Any] | None = None): + final_raw_state = raw_state if raw_state is not None else {} + + self._state_proxy: StateProxy = StateProxy(final_raw_state) + self.ingest(final_raw_state) # This step saves the properties associated with the instance for attribute in calculated_properties_per_state_type.get(self.__class__, []): @@ -678,7 +737,7 @@ def _set_state_item(self, key: str, value: Any): self._state_proxy[key] = value - def subscribe_mutation(self, path: Union[str, List[str]], handler: Callable[['State'], None], initial_triggered: bool = False) -> None: + def subscribe_mutation(self, path: Union[str, List[str]], handler: Callable[..., None], initial_triggered: bool = False) -> None: """ Automatically triggers a handler when a mutation occurs in the state. @@ -706,7 +765,7 @@ def subscribe_mutation(self, path: Union[str, List[str]], handler: Callable[['St final_handler = functools.partial(handler, self) for i, path_part in enumerate(path_parts): if i == len(path_parts) - 1: - local_mutation = MutationSubscription(path_parts[-1], final_handler) + local_mutation = MutationSubscription(p, final_handler) state_proxy.local_mutation_subscriptions.append(local_mutation) # At startup, the application must be informed of the diff --git a/tests/backend/test_core.py b/tests/backend/test_core.py index 0f432d320..18231b383 100644 --- a/tests/backend/test_core.py +++ b/tests/backend/test_core.py @@ -503,6 +503,27 @@ def _increment_counter2(state): except RecursionError: assert True + def test_subscribe_mutation_should_raise_accept_event_handler_as_callback(self): + """ + Tests that the handler that subscribes to a mutation can accept an event as a parameter + """ + # Assign + def _increment_counter(state, payload, context: dict, ui): + state['my_counter'] += 1 + + # Assert + assert payload['mutation_previous_value'] == 1 + assert payload['mutation_value'] == 2 + assert context['mutation'] == 'a' + + _state = WriterState({"a": 1, "my_counter": 0}) + _state.user_state.get_mutations_as_dict() + + # Acts + _state.subscribe_mutation('a', _increment_counter) + _state['a'] = 2 + + def test_subscribe_mutation_with_typed_state_should_manage_mutation(self): """ Tests that a mutation handler is triggered on a typed state and can use attributes directly. From 33f2d8e33a06ba97090d5c8a6ed091cd37645e08 Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Sun, 4 Aug 2024 10:46:12 +0200 Subject: [PATCH 11/12] feat: trigger a calculated property on mutation * feat: subscribe_mutation supports an event handler as a function * feat: subscribe_mutation supports an async event handler as a function --- docs/framework/event-handlers.mdx | 41 ++++- src/writer/app_runner.py | 133 +++++++------- src/writer/core.py | 295 +++++++++++++++++++++++------- tests/backend/test_core.py | 26 ++- 4 files changed, 356 insertions(+), 139 deletions(-) diff --git a/docs/framework/event-handlers.mdx b/docs/framework/event-handlers.mdx index c91cfadc4..cbb41b5dd 100644 --- a/docs/framework/event-handlers.mdx +++ b/docs/framework/event-handlers.mdx @@ -141,7 +141,8 @@ def hande_click_cleaner(state): You can subscribe to mutations on a specific key in the state. This is useful when you want to trigger a function every time a specific key is mutated. -```python + +```python simple subscription import writer as wf def _increment_counter(state): @@ -153,10 +154,44 @@ state.subscribe_mutation('a', _increment_counter) state['a'] = 2 # trigger _increment_counter mutation ``` -```python -state.subscribe_mutation('a.b', _increment_counter) # subscribe to nested key +```python multiple subscriptions +import writer as wf + +def _increment_counter(state): + state['my_counter'] += 1 + +state = wf.init_state({ + 'title': 'Hello', + 'app': {'title', 'Writer Framework'}, + 'my_counter': 0} +) + state.subscribe_mutation(['title', 'app.title'], _increment_counter) # subscribe to multiple keys + +state['title'] = "Hello Pigeon" # trigger _increment_counter mutation +``` + +```python trigger event handler +import writer as wf + +def _increment_counter(state, context: dict, payload: dict, session: dict, ui: WriterUIManager): + if context['event'] == 'mutation' and context['mutation'] == 'a': + if payload['previous_value'] > payload['new_value']: + state['my_counter'] += 1 + +state = wf.init_state({"a": 1, "my_counter": 0}) +state.subscribe_mutation('a', _increment_counter) + +state['a'] = 2 # increment my_counter +state['a'] = 3 # increment my_counter +state['a'] = 2 # do nothing ``` + + + +`subscribe_mutation` is compatible with event handler signature. It will accept all the arguments +of the event handler (`context`, `payload`, ...). + ## Receiving a payload diff --git a/src/writer/app_runner.py b/src/writer/app_runner.py index a6cb0fa90..3f0bc2aa4 100644 --- a/src/writer/app_runner.py +++ b/src/writer/app_runner.py @@ -19,7 +19,7 @@ from watchdog.observers.polling import PollingObserver from writer import VERSION -from writer.core import EventHandlerRegistry, MiddlewareRegistry, WriterSession +from writer.core import EventHandlerRegistry, MiddlewareRegistry, WriterSession, use_request_context from writer.core_ui import ingest_bmc_component_tree from writer.ss_types import ( AppProcessServerRequest, @@ -232,71 +232,72 @@ def _handle_message(self, session_id: str, request: AppProcessServerRequest) -> """ import writer - session = None - type = request.type - - if type == "sessionInit": - si_req_payload = InitSessionRequestPayload.parse_obj( - request.payload) - return AppProcessServerResponse( - status="ok", - status_message=None, - payload=self._handle_session_init(si_req_payload) - ) - - session = writer.session_manager.get_session(session_id) - if not session: - raise MessageHandlingException("Session not found.") - session.update_last_active_timestamp() - - if type == "checkSession": - return AppProcessServerResponse( - status="ok", - status_message=None, - payload=None - ) - - if type == "event": - ev_req_payload = WriterEvent.parse_obj(request.payload) - return AppProcessServerResponse( - status="ok", - status_message=None, - payload=self._handle_event(session, ev_req_payload) - ) - - if type == "stateEnquiry": - return AppProcessServerResponse( - status="ok", - status_message=None, - payload=self._handle_state_enquiry(session) - ) - - if type == "stateContent": - return AppProcessServerResponse( - status="ok", - status_message=None, - payload=self._handle_state_content(session) - ) - - if type == "setUserinfo": - session.userinfo = request.payload - return AppProcessServerResponse( - status="ok", - status_message=None, - payload=None - ) - - if self.mode == "edit" and type == "componentUpdate": - cu_req_payload = ComponentUpdateRequestPayload.parse_obj( - request.payload) - self._handle_component_update(session, cu_req_payload) - return AppProcessServerResponse( - status="ok", - status_message=None, - payload=None - ) - - raise MessageHandlingException("Invalid event.") + with use_request_context(session_id, request): + session = None + type = request.type + + if type == "sessionInit": + si_req_payload = InitSessionRequestPayload.parse_obj( + request.payload) + return AppProcessServerResponse( + status="ok", + status_message=None, + payload=self._handle_session_init(si_req_payload) + ) + + session = writer.session_manager.get_session(session_id) + if not session: + raise MessageHandlingException("Session not found.") + session.update_last_active_timestamp() + + if type == "checkSession": + return AppProcessServerResponse( + status="ok", + status_message=None, + payload=None + ) + + if type == "event": + ev_req_payload = WriterEvent.parse_obj(request.payload) + return AppProcessServerResponse( + status="ok", + status_message=None, + payload=self._handle_event(session, ev_req_payload) + ) + + if type == "stateEnquiry": + return AppProcessServerResponse( + status="ok", + status_message=None, + payload=self._handle_state_enquiry(session) + ) + + if type == "stateContent": + return AppProcessServerResponse( + status="ok", + status_message=None, + payload=self._handle_state_content(session) + ) + + if type == "setUserinfo": + session.userinfo = request.payload + return AppProcessServerResponse( + status="ok", + status_message=None, + payload=None + ) + + if self.mode == "edit" and type == "componentUpdate": + cu_req_payload = ComponentUpdateRequestPayload.parse_obj( + request.payload) + self._handle_component_update(session, cu_req_payload) + return AppProcessServerResponse( + status="ok", + status_message=None, + payload=None + ) + + raise MessageHandlingException("Invalid event.") def _execute_user_code(self) -> None: """ diff --git a/src/writer/core.py b/src/writer/core.py index c8e4bcb7e..7c94b19aa 100644 --- a/src/writer/core.py +++ b/src/writer/core.py @@ -24,6 +24,7 @@ from typing import ( TYPE_CHECKING, Any, + Awaitable, Callable, Dict, Generator, @@ -61,7 +62,30 @@ import polars from writer.app_runner import AppProcess + from writer.ss_types import AppProcessServerRequest +@dataclasses.dataclass +class CurrentRequest: + session_id: str + request: 'AppProcessServerRequest' + +_current_request: ContextVar[Optional[CurrentRequest]] = ContextVar("current_request", default=None) + +@contextlib.contextmanager +def use_request_context(session_id: str, request: 'AppProcessServerRequest'): + """ + Context manager to set the current request context. + + >>> session_id = "xxxxxxxxxxxxxxxxxxxxxxxxx" + >>> request = AppProcessServerRequest(type='event', payload=EventPayload(event='my_event')) + >>> with use_request_context(session_id, request): + >>> pass + """ + try: + _current_request.set(CurrentRequest(session_id, request)) + yield + finally: + _current_request.set(None) def get_app_process() -> 'AppProcess': """ @@ -121,8 +145,11 @@ class MutationSubscription: >>> m = MutationSubscription(path="a.c", handler=myhandler) """ + type: Literal['subscription', 'property'] path: str handler: Callable # Handler to execute when mutation happens + state: 'State' + property_name: Optional[str] = None def __post_init__(self): if len(self.path) == 0: @@ -417,21 +444,31 @@ def __setitem__(self, key: str, raw_value: Any) -> None: if not isinstance(key, str): raise ValueError( f"State keys must be strings. Received {str(key)} ({type(key)}).") - previous_value = self.state.get(key) + old_value = self.state.get(key) self.state[key] = raw_value for local_mutation in self.local_mutation_subscriptions: if local_mutation.local_path == key: - from writer.ui import WriterUIManager - - context = {"mutation": local_mutation.path} - payload = { - "mutation_previous_value": previous_value, - "mutation_value": raw_value - } - ui = WriterUIManager() - args = build_writer_func_arguments(local_mutation.handler, {"context": context, "payload": payload, "ui": ui}) - local_mutation.handler(*args) + if local_mutation.type == 'subscription': + context_data = { + "event": "mutation", + "mutation": local_mutation.path + } + payload = { + "previous_value": old_value, + "new_value": raw_value + } + + writer_event_handler_invoke(local_mutation.handler, { + "state": local_mutation.state, + "context": context_data, + "payload": payload, + "session": _event_handler_session_info(), + "ui": _event_handler_ui_manager() + }) + elif local_mutation.type == 'property': + assert local_mutation.property_name is not None + self[local_mutation.property_name] = local_mutation.handler(local_mutation.state) self._apply_raw(f"+{key}") @@ -619,7 +656,7 @@ def bind_annotations_to_state_proxy(cls, klass): class State(metaclass=StateMeta): - def __init__(self, raw_state: Dict[str, Any] | None = None): + def __init__(self, raw_state: Optional[Dict[str, Any]] = None): final_raw_state = raw_state if raw_state is not None else {} self._state_proxy: StateProxy = StateProxy(final_raw_state) @@ -737,12 +774,15 @@ def _set_state_item(self, key: str, value: Any): self._state_proxy[key] = value - def subscribe_mutation(self, path: Union[str, List[str]], handler: Callable[..., None], initial_triggered: bool = False) -> None: + def subscribe_mutation(self, + path: Union[str, List[str]], + handler: Callable[..., Union[None, Awaitable[None]]], + initial_triggered: bool = False) -> None: """ Automatically triggers a handler when a mutation occurs in the state. >>> def _increment_counter(state): - >>> state_proxy['my_counter'] += 1 + >>> state['my_counter'] += 1 >>> >>> state = WriterState({'a': 1, 'c': {'a': 1, 'b': 3}, 'my_counter': 0}) >>> state.subscribe_mutation('a', _increment_counter) @@ -751,6 +791,15 @@ def subscribe_mutation(self, path: Union[str, List[str]], handler: Callable[..., >>> state['a'] = 3 # will trigger _increment_counter >>> state['c']['a'] = 2 # will trigger _increment_counter + subscribe mutation accepts the signature of an event handler. + + >>> def _increment_counter(state, payload, context, session, ui): + >>> state['my_counter'] += 1 + >>> + >>> state = WriterState({'a': 1, 'my_counter': 0}) + >>> state.subscribe_mutation('a', _increment_counter) + >>> state['a'] = 2 # will trigger _increment_counter + :param path: path of mutation to monitor :param func: handler to call when the path is mutated """ @@ -762,21 +811,68 @@ def subscribe_mutation(self, path: Union[str, List[str]], handler: Callable[..., for p in path_list: state_proxy = self._state_proxy path_parts = p.split(".") - final_handler = functools.partial(handler, self) for i, path_part in enumerate(path_parts): if i == len(path_parts) - 1: - local_mutation = MutationSubscription(p, final_handler) + local_mutation = MutationSubscription('subscription', p, handler, self) state_proxy.local_mutation_subscriptions.append(local_mutation) # At startup, the application must be informed of the # existing states. To cause this, we trigger manually # the handler. if initial_triggered is True: - final_handler() + writer_event_handler_invoke(handler, { + "state": self, + "context": {"event": "init"}, + "payload": {}, + "session": {}, + "ui": _event_handler_ui_manager() + }) + elif path_part in state_proxy: state_proxy = state_proxy[path_part] else: - raise ValueError("Mutation subscription failed - {p} not found in state") + raise ValueError(f"Mutation subscription failed - {p} not found in state") + + def calculated_property(self, + property_name: str, + path: Union[str, List[str]], + handler: Callable[..., Union[None, Awaitable[None]]]) -> None: + """ + Update a calculated property when a mutation triggers + + This method is dedicated to be used through a calculated property. It is not + recommended to invoke it directly. + + >>> class MyState(State): + >>> title: str + >>> + >>> wf.property('title') + >>> def title_upper(self): + >>> return self.title.upper() + + Usage + ===== + + >>> state = wf.init_state({'title': 'hello world'}) + >>> state.calculated_property('title_upper', 'title', lambda state: state.title.upper()) + """ + if isinstance(path, str): + path_list = [path] + else: + path_list = path + + for p in path_list: + state_proxy = self._state_proxy + path_parts = p.split(".") + for i, path_part in enumerate(path_parts): + if i == len(path_parts) - 1: + local_mutation = MutationSubscription('property', p, handler, self, property_name) + state_proxy.local_mutation_subscriptions.append(local_mutation) + state_proxy[property_name] = handler(self) + elif path_part in state_proxy: + state_proxy = state_proxy[path_part] + else: + raise ValueError(f"Property subscription failed - {p} not found in state") class WriterState(State): @@ -824,7 +920,10 @@ def get_clone(self) -> 'WriterState': "The state may contain unpickable objects, such as modules.", traceback.format_exc()) return substitute_state - return self.__class__(cloned_user_state, cloned_mail) + + cloned_state = self.__class__(cloned_user_state, cloned_mail) + _clone_mutation_subscriptions(cloned_state, self) + return cloned_state def add_mail(self, type: str, payload: Any) -> None: mail_item = { @@ -969,7 +1068,7 @@ def __init__(self, middleware: Callable): @contextlib.contextmanager def execute(self, args: dict): - middleware_args = build_writer_func_arguments(self.middleware, args) + middleware_args = writer_event_handler_build_arguments(self.middleware, args) it = self.middleware(*middleware_args) try: yield from it @@ -993,7 +1092,7 @@ def executors(self) -> List[MiddlewareExecutor]: Retrieves middlewares prepared for execution >>> executors = middleware_registry.executors() - >>> result = handle_with_middlewares_executor(executors, lambda state: pass, {'state': {}, 'payload': {}}) + >>> result = writer_event_handler_invoke_with_middlewares(executors, lambda state: pass, {'state': {}, 'payload': {}}) """ return self.registry @@ -1596,21 +1695,14 @@ def _call_handler_callable( raise ValueError(f"""Invalid handler. Couldn't find the handler "{ handler }".""") # Preparation of arguments - from writer.ui import WriterUIManager - context_data = self.evaluator.get_context_data(instance_path) context_data['event'] = event_type writer_args = { 'state': self.session_state, 'payload': payload, 'context': context_data, - 'session': { - 'id': self.session.session_id, - 'cookies': self.session.cookies, - 'headers': self.session.headers, - 'userinfo': self.session.userinfo or {} - }, - 'ui': WriterUIManager() + 'session':_event_handler_session_info(), + 'ui': _event_handler_ui_manager() } # Invocation of handler @@ -1620,7 +1712,7 @@ def _call_handler_callable( contextlib.redirect_stdout(io.StringIO()) as f: middlewares_executors = current_app_process.middleware_registry.executors() - result = handle_with_middlewares_executor(middlewares_executors, callable_handler, writer_args) + result = writer_event_handler_invoke_with_middlewares(middlewares_executors, callable_handler, writer_args) captured_stdout = f.getvalue() if captured_stdout: @@ -2166,7 +2258,7 @@ class Property(): def __init__(self, func): self.func = func - self.instances = set() + self.initialized = False self.property_name = None def __call__(self, *args, **kwargs): @@ -2174,7 +2266,7 @@ def __call__(self, *args, **kwargs): def __set_name__(self, owner: Type[State], name: str): """ - Saves the calculated properties when loading the class. + Saves the calculated properties when loading a State class. """ if owner not in calculated_properties_per_state_type: calculated_properties_per_state_type[owner] = [] @@ -2186,13 +2278,14 @@ def __get__(self, instance: State, cls): """ This mechanism retrieves the property instance. """ - property_name = self.property_name - if instance not in self.instances: - def calculated_property_handler(state): - instance._state_proxy[property_name] = self.func(state) + args = inspect.getfullargspec(self.func) + if len(args.args) > 1: + logging.warning(f"Wrong signature for calculated property '{instance.__class__.__name__}:{self.property_name}'. It must declare only self argument.") + return None - instance.subscribe_mutation(path, calculated_property_handler, initial_triggered=True) - self.instances.add(instance) + if self.initialized is False: + instance.calculated_property(property_name=self.property_name, path=path, handler=self.func) + self.initialized = True return self.func(instance) @@ -2209,6 +2302,25 @@ def wrapped(*args, **kwargs): session_manager.add_verifier(func) return wrapped + +def get_session() -> Optional[WriterSession]: + """ + Retrieves the current session. + + This function works exclusively in the context of a request. + """ + req = _current_request.get() + if req is None: + return None + + session_id = req.session_id + session = session_manager.get_session(session_id) + if not session: + return None + + return session + + def reset_base_component_tree() -> None: """ Reset the base component tree to zero @@ -2218,8 +2330,56 @@ def reset_base_component_tree() -> None: global base_component_tree base_component_tree = core_ui.build_base_component_tree() +def _clone_mutation_subscriptions(session_state: State, app_state: State, root_state: Optional['State'] = None) -> None: + """ + clone subscriptions on mutations between the initial state of the application and the state created for the session + + >>> state = wf.init_state({"counter": 0}) + >>> state.subscribe_mutation("counter", lambda state: print(state["counter"])) -def handler_executor(callable_handler: Callable, writer_args: dict) -> Any: + >>> session_state = state.get_clone() + + :param session_state: + :param app_state: + :param root_state: + """ + state_proxy_app = app_state._state_proxy + state_proxy_session = session_state._state_proxy + + state_proxy_session.local_mutation_subscriptions = [] + + _root_state = root_state if root_state is not None else session_state + for mutation_subscription in state_proxy_app.local_mutation_subscriptions: + new_mutation_subscription = copy.copy(mutation_subscription) + new_mutation_subscription.state = _root_state if new_mutation_subscription.type == "subscription" else session_state + session_state._state_proxy.local_mutation_subscriptions.append(new_mutation_subscription) + + + +def writer_event_handler_build_arguments(func: Callable, writer_args: dict) -> List[Any]: + """ + Constructs the list of arguments based on the signature of the function + which can be a handler or middleware. + + >>> def my_event_handler(state, context): + >>> yield + + >>> args = writer_event_handler_build_arguments(my_event_handler, {'state': {}, 'payload': {}, 'context': {"target": '11'}, 'session': None, 'ui': None}) + >>> [{}, {"target": '11'}] + + :param func: the function that will be called + :param writer_args: the possible arguments in writer (state, payload, ...) + """ + handler_args = inspect.getfullargspec(func).args + func_args = [] + for arg in handler_args: + if arg in writer_args: + func_args.append(writer_args[arg]) + + return func_args + + +def writer_event_handler_invoke(callable_handler: Callable, writer_args: dict) -> Any: """ Runs a handler based on its signature. @@ -2229,13 +2389,13 @@ def handler_executor(callable_handler: Callable, writer_args: dict) -> Any: >>> def my_handler(state): >>> state['a'] = 2 >>> - >>> handler_executor(my_handler, {'state': {'a': 1}, 'payload': None, 'context': None, 'session': None, 'ui': None}) + >>> writer_event_handler_invoke(my_handler, {'state': {'a': 1}, 'payload': None, 'context': None, 'session': None, 'ui': None}) """ is_async_handler = inspect.iscoroutinefunction(callable_handler) if (not callable(callable_handler) and not is_async_handler): raise ValueError("Invalid handler. The handler isn't a callable object.") - handler_args = build_writer_func_arguments(callable_handler, writer_args) + handler_args = writer_event_handler_build_arguments(callable_handler, writer_args) if is_async_handler: async_wrapper = _async_wrapper_internal(callable_handler, handler_args) @@ -2245,7 +2405,7 @@ def handler_executor(callable_handler: Callable, writer_args: dict) -> Any: return result -def handle_with_middlewares_executor(middlewares_executors: List[MiddlewareExecutor], callable_handler: Callable, writer_args: dict) -> Any: +def writer_event_handler_invoke_with_middlewares(middlewares_executors: List[MiddlewareExecutor], callable_handler: Callable, writer_args: dict) -> Any: """ Runs the middlewares then the handler. This function allows you to manage exceptions that are triggered in middleware @@ -2257,36 +2417,14 @@ def handle_with_middlewares_executor(middlewares_executors: List[MiddlewareExecu >>> yield >>> executor = MiddlewareExecutor(my_middleware, {'state': {}, 'payload': None, 'context': None, 'session': None, 'ui': None}) - >>> handle_with_middlewares_executor([executor], my_handler, {'state': {}, 'payload': None, 'context': None, 'session': None, 'ui': None} + >>> writer_event_handler_invoke_with_middlewares([executor], my_handler, {'state': {}, 'payload': None, 'context': None, 'session': None, 'ui': None} """ if len(middlewares_executors) == 0: - return handler_executor(callable_handler, writer_args) + return writer_event_handler_invoke(callable_handler, writer_args) else: executor = middlewares_executors[0] with executor.execute(writer_args): - return handle_with_middlewares_executor(middlewares_executors[1:], callable_handler, writer_args) - -def build_writer_func_arguments(func: Callable, writer_args: dict) -> List[Any]: - """ - Constructs the list of arguments based on the signature of the function - which can be a handler or middleware. - - >>> def my_event_handler(state, context): - >>> yield - - >>> args = build_writer_func_arguments(my_event_handler, {'state': {}, 'payload': {}, 'context': {"target": '11'}, 'session': None, 'ui': None}) - >>> [{}, {"target": '11'}] - - :param func: the function that will be called - :param writer_args: the possible arguments in writer (state, payload, ...) - """ - handler_args = inspect.getfullargspec(func).args - func_args = [] - for arg in handler_args: - if arg in writer_args: - func_args.append(writer_args[arg]) - - return func_args + return writer_event_handler_invoke_with_middlewares(middlewares_executors[1:], callable_handler, writer_args) async def _async_wrapper_internal(callable_handler: Callable, arg_values: List[Any]) -> Any: @@ -2331,6 +2469,27 @@ def _assert_record_match_list_of_records(df: List[Dict[str, Any]], record: Dict[ if columns != columns_record: raise ValueError(f"Columns mismatch. Expected {columns}, got {columns_record}") +def _event_handler_session_info() -> Dict[str, Any]: + """ + Returns the session information for the current event handler. + + This information is exposed in the session parameter of a handler + + """ + current_session = get_session() + session_info: Dict[str, Any] = {} + if current_session is not None: + session_info['id'] = current_session.session_id + session_info['cookies'] = current_session.cookies + session_info['headers'] = current_session.headers + session_info['userinfo'] = current_session.userinfo or {} + + return session_info + +def _event_handler_ui_manager(): + from writer.ui import WriterUIManager + return WriterUIManager() + def _split_record_as_pandas_record_and_index(param: dict, index_columns: list) -> Tuple[dict, tuple]: """ diff --git a/tests/backend/test_core.py b/tests/backend/test_core.py index 18231b383..33c7d13ad 100644 --- a/tests/backend/test_core.py +++ b/tests/backend/test_core.py @@ -477,6 +477,28 @@ def _increment_counter2(state): assert mutations['+my_counter'] == 1 assert mutations['+my_counter2'] == 1 + def test_subscribe_mutation_should_work_with_async_event_handler(self): + """ + Tests that multiple handlers can be triggered in cascade if one of them modifies a value + that is listened to by another handler during a mutation. + """ + # Assign + async def _increment_counter(state): + state['my_counter'] += 1 + + _state = WriterState({"a": 1, "my_counter": 0}) + _state.user_state.get_mutations_as_dict() + + # Acts + _state.subscribe_mutation('a', _increment_counter) + _state['a'] = 2 + + # Assert + assert _state['my_counter'] == 1 + + mutations = _state.user_state.get_mutations_as_dict() + assert mutations['+my_counter'] == 1 + def test_subscribe_mutation_should_raise_error_on_infinite_cascading(self): """ Tests that an infinite recursive loop is detected and an error is raised if mutations cascade @@ -512,9 +534,9 @@ def _increment_counter(state, payload, context: dict, ui): state['my_counter'] += 1 # Assert - assert payload['mutation_previous_value'] == 1 - assert payload['mutation_value'] == 2 assert context['mutation'] == 'a' + assert payload['previous_value'] == 1 + assert payload['new_value'] == 2 _state = WriterState({"a": 1, "my_counter": 0}) _state.user_state.get_mutations_as_dict() From c2c582258ba2a789e024183c57ef65b3993829fb Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Mon, 12 Aug 2024 16:31:43 +0200 Subject: [PATCH 12/12] feat: trigger a calculated property on mutation * fix: handle dot separated expression on subscribe mutation --- src/writer/core.py | 44 +++++++++++++++++++++++++++++++++++--- tests/backend/test_core.py | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/writer/core.py b/src/writer/core.py index 7c94b19aa..7502582ce 100644 --- a/src/writer/core.py +++ b/src/writer/core.py @@ -155,7 +155,7 @@ def __post_init__(self): if len(self.path) == 0: raise ValueError("path cannot be empty.") - path_parts = self.path.split(".") + path_parts = parse_state_variable_expression(self.path) for part in path_parts: if len(part) == 0: raise ValueError(f"path {self.path} cannot have empty parts.") @@ -169,7 +169,7 @@ def local_path(self) -> str: >>> m.local_path >>> "c" """ - path_parts = self.path.split(".") + path_parts = parse_state_variable_expression(self.path) return path_parts[-1] class StateRecursionWatcher(): @@ -800,6 +800,15 @@ def subscribe_mutation(self, >>> state.subscribe_mutation('a', _increment_counter) >>> state['a'] = 2 # will trigger _increment_counter + subscribe mutation accepts escaped dot expressions to encode key that contains `dot` separator + + >>> def _increment_counter(state, payload, context, session, ui): + >>> state['my_counter'] += 1 + >>> + >>> state = WriterState({'a.b': 1, 'my_counter': 0}) + >>> state.subscribe_mutation('a\.b', _increment_counter) + >>> state['a.b'] = 2 # will trigger _increment_counter + :param path: path of mutation to monitor :param func: handler to call when the path is mutated """ @@ -810,7 +819,7 @@ def subscribe_mutation(self, for p in path_list: state_proxy = self._state_proxy - path_parts = p.split(".") + path_parts = parse_state_variable_expression(p) for i, path_part in enumerate(path_parts): if i == len(path_parts) - 1: local_mutation = MutationSubscription('subscription', p, handler, self) @@ -2355,6 +2364,35 @@ def _clone_mutation_subscriptions(session_state: State, app_state: State, root_s session_state._state_proxy.local_mutation_subscriptions.append(new_mutation_subscription) +def parse_state_variable_expression(p: str): + """ + Parses a state variable expression into a list of parts. + + >>> parse_state_variable_expression("a.b.c") + >>> ["a", "b", "c"] + + >>> parse_state_variable_expression("a\.b.c") + >>> ["a.b", "c"] + """ + parts = [] + it = 0 + last_split = 0 + while it < len(p): + if p[it] == '\\': + it += 2 + elif p[it] == '.': + new_part = p[last_split: it] + parts.append(new_part.replace('\\.', '.')) + + last_split = it + 1 + it += 1 + else: + it += 1 + + new_part = p[last_split: len(p)] + parts.append(new_part.replace('\\.', '.')) + return parts + def writer_event_handler_build_arguments(func: Callable, writer_args: dict) -> List[Any]: """ diff --git a/tests/backend/test_core.py b/tests/backend/test_core.py index 33c7d13ad..b08c68983 100644 --- a/tests/backend/test_core.py +++ b/tests/backend/test_core.py @@ -26,6 +26,7 @@ StateSerialiserException, WriterState, import_failure, + parse_state_variable_expression, ) from writer.core_ui import Component from writer.ss_types import WriterEvent @@ -573,6 +574,30 @@ def cumulative_sum(state: MyState): # Assert assert initial_state['total'] == 4 + def test_subscribe_mutation_should_manage_escaping_in_subscription(self): + """ + Tests that a key that contains a `.` can be used to subscribe to + a mutation using the escape character. + """ + with writer_fixtures.new_app_context(): + # Assign + def cumulative_sum(state): + state['total'] += state['a.b'] + + initial_state = wf.init_state({ + "a.b": 0, + "total": 0 + }) + + initial_state.subscribe_mutation('a\.b', cumulative_sum) + + # Acts + initial_state['a.b'] = 1 + initial_state['a.b'] = 3 + + # Assert + assert initial_state['total'] == 4 + class TestWriterState: # Initialised manually @@ -1667,3 +1692,15 @@ def counter_sum(self) -> int: mutations = state.user_state.get_mutations_as_dict() assert '+counter_sum' in mutations assert mutations['+counter_sum'] == 8 + + +def test_parse_state_variable_expression_should_process_expression(): + """ + Test that the parse_state_variable_expression function will process + the expression correctly + """ + # When + assert parse_state_variable_expression('features') == ['features'] + assert parse_state_variable_expression('features.eyes') == ['features', 'eyes'] + assert parse_state_variable_expression('features\.eyes') == ['features.eyes'] + assert parse_state_variable_expression('features\.eyes.color') == ['features.eyes', 'color']