Skip to content

Commit

Permalink
feat: trigger a calculated property on mutation
Browse files Browse the repository at this point in the history
* feat: implement calculated properties
  • Loading branch information
FabienArcellier committed Jul 30, 2024
1 parent a7fb9d4 commit e479327
Show file tree
Hide file tree
Showing 3 changed files with 139 additions and 8 deletions.
4 changes: 4 additions & 0 deletions src/writer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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


Expand Down
82 changes: 74 additions & 8 deletions src/writer/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import copy
import dataclasses
import datetime
import functools
import inspect
import io
import json
Expand All @@ -14,6 +15,7 @@
import secrets
import time
import traceback
import types
import urllib.request
from functools import partial
from multiprocessing.process import BaseProcess
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
"""
Expand Down
61 changes: 61 additions & 0 deletions tests/backend/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit e479327

Please sign in to comment.