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