From e479327036404a36187c4ec82520e167e2e66403 Mon Sep 17 00:00:00 2001 From: Fabien Arcellier Date: Mon, 29 Jul 2024 11:37:43 +0200 Subject: [PATCH] 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 | 61 ++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 8 deletions(-) diff --git a/src/writer/__init__.py b/src/writer/__init__.py index b93a03f1d..d5767fe66 100644 --- a/src/writer/__init__.py +++ b/src/writer/__init__.py @@ -16,6 +16,9 @@ session_manager, session_verifier, ) +from writer.core import ( + writerproperty as property, +) from writer.ui import WriterUIManager VERSION = importlib.metadata.version("writer") @@ -93,6 +96,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 8d321fd9f..f42d0f049 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 functools import partial from multiprocessing.process import BaseProcess @@ -66,7 +68,6 @@ def get_app_process() -> 'AppProcess': raise RuntimeError( "Failed to retrieve the AppProcess: running in wrong context") - class Config: is_mail_enabled_for_log: bool = False @@ -432,7 +433,6 @@ def get_annotations(instance) -> Dict[str, Any]: ann = {} return ann - class StateMeta(type): """ Constructs a class at runtime that extends WriterState or State @@ -483,16 +483,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. @@ -601,7 +600,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. @@ -1586,6 +1585,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 26bce1dec..a4c5eb30e 100644 --- a/tests/backend/test_core.py +++ b/tests/backend/test_core.py @@ -1127,3 +1127,64 @@ def session_verifier_2(headers: Dict[str, str]) -> None: None ) assert s_invalid is None + +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