From 58343cec2dfc830034ff1ffa4fff297625400e59 Mon Sep 17 00:00:00 2001 From: Sassan Haradji Date: Sat, 4 May 2024 17:39:33 +0400 Subject: [PATCH] feat(autorun): add `auto_call` and `reactive` options to autorun to control whether the autorun should call the function automatically when the comparator's value changes and whether it shouldn't automatically call it but yet register a change so that when it is manually called the next time, it will call the function. --- CHANGELOG.md | 4 ++ redux/autorun.py | 19 +++-- redux/basic_types.py | 4 +- redux/serialization_mixin.py | 2 +- tests/test_autorun.py | 123 ++++++++++++++++++++++++++++++++- tests/test_monitor_fixtures.py | 4 +- tests/test_serialization.py | 2 +- tests/test_weakref.py | 4 +- 8 files changed, 150 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cee96b..e141555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ - refactor(autorun)!: setting `initial_run` option of autorun to `False` used to make the autorun simply not call the function on initialization, now it makes sure the function is not called until the selector's value actually changes +- feat(autorun): add `auto_call` and `reactive` options to autorun to control whether + the autorun should call the function automatically when the comparator's value + changes and whether it shouldn't automatically call it but yet register a change + so that when it is manually called the next time, it will call the function. ## Version 0.14.5 diff --git a/redux/autorun.py b/redux/autorun.py index 2dcee39..11beaf0 100644 --- a/redux/autorun.py +++ b/redux/autorun.py @@ -1,6 +1,7 @@ # ruff: noqa: D100, D101, D102, D103, D104, D105, D107 from __future__ import annotations +import functools import inspect import weakref from asyncio import Task, iscoroutine @@ -43,9 +44,13 @@ def __init__( # noqa: PLR0913 | Callable[[SelectorOutput, SelectorOutput], AutorunOriginalReturnType], options: AutorunOptions[AutorunOriginalReturnType], ) -> None: + if not options.reactive and options.auto_call: + msg = '`reactive` must be `True` if `auto_call` is `True`' + raise ValueError(msg) self._store = store self._selector = selector self._comparator = comparator + self._should_be_called = False if options.keep_ref: self._func = func elif inspect.ismethod(func): @@ -65,9 +70,12 @@ def __init__( # noqa: PLR0913 | weakref.ref[Callable[[AutorunOriginalReturnType], Any]] ] = set() - self._check_and_call(store._state, call=self._options.initial_run) # noqa: SLF001 + self._check_and_call(store._state, call=self._options.initial_call) # noqa: SLF001 - self.unsubscribe = store.subscribe(self._check_and_call) + if self._options.reactive: + self.unsubscribe = store.subscribe( + functools.partial(self._check_and_call, call=self._options.auto_call), + ) def inform_subscribers( self: Autorun[ @@ -158,7 +166,8 @@ def _check_and_call( comparator_result = self._comparator(state) except AttributeError: return - if comparator_result != self._last_comparator_result: + if self._should_be_called or comparator_result != self._last_comparator_result: + self._should_be_called = False previous_result = self._last_selector_result self._last_selector_result = selector_result self._last_comparator_result = comparator_result @@ -174,6 +183,8 @@ def _check_and_call( if iscoroutine(self._latest_value) and create_task: create_task(self._latest_value, callback=self._task_callback) self.inform_subscribers() + else: + self._should_be_called = True else: self.unsubscribe() @@ -189,7 +200,7 @@ def __call__( ) -> AutorunOriginalReturnType: state = self._store._state # noqa: SLF001 if state is not None: - self._check_and_call(state) + self._check_and_call(state, call=True) return cast(AutorunOriginalReturnType, self._latest_value) def __repr__( diff --git a/redux/basic_types.py b/redux/basic_types.py index b6a89cc..74413ee 100644 --- a/redux/basic_types.py +++ b/redux/basic_types.py @@ -117,7 +117,9 @@ class CreateStoreOptions(Immutable, Generic[Action, Event]): class AutorunOptions(Immutable, Generic[AutorunOriginalReturnType]): default_value: AutorunOriginalReturnType | None = None - initial_run: bool = True + initial_call: bool = True + auto_call: bool = True + reactive: bool = True keep_ref: bool = True subscribers_initial_run: bool = True subscribers_keep_ref: bool = True diff --git a/redux/serialization_mixin.py b/redux/serialization_mixin.py index a13a554..7334b77 100644 --- a/redux/serialization_mixin.py +++ b/redux/serialization_mixin.py @@ -29,7 +29,7 @@ def serialize_value( return [cls.serialize_value(i) for i in obj] if is_immutable(obj): return cls._serialize_dataclass_to_dict(obj) - msg = f'Unable to serialize object with type `{type(obj)}`.' + msg = f'Unable to serialize object with type `{type(obj)}`' raise TypeError(msg) @classmethod diff --git a/tests/test_autorun.py b/tests/test_autorun.py index 9c339c8..e788090 100644 --- a/tests/test_autorun.py +++ b/tests/test_autorun.py @@ -4,11 +4,13 @@ import re from dataclasses import replace from typing import TYPE_CHECKING, Generator +from unittest.mock import call import pytest from immutable import Immutable from redux.basic_types import ( + AutorunOptions, BaseAction, CompleteReducerResult, CreateStoreOptions, @@ -20,6 +22,8 @@ from redux.main import Store if TYPE_CHECKING: + from pytest_mock import MockerFixture + from redux_pytest.fixtures import StoreSnapshot @@ -30,10 +34,15 @@ class StateType(Immutable): class IncrementAction(BaseAction): ... +class DecrementAction(BaseAction): ... + + class IncrementByTwoAction(BaseAction): ... -Action = IncrementAction | IncrementByTwoAction | InitAction | FinishAction +Action = ( + IncrementAction | DecrementAction | IncrementByTwoAction | InitAction | FinishAction +) def reducer( @@ -48,6 +57,9 @@ def reducer( if isinstance(action, IncrementAction): return replace(state, value=state.value + 1) + if isinstance(action, DecrementAction): + return replace(state, value=state.value - 1) + if isinstance(action, IncrementByTwoAction): return replace(state, value=state.value + 2) @@ -189,3 +201,112 @@ def render(value: int) -> int: r'.*\(func: \.render at .*>, last_value: 1\)$', repr(render), ) + + +def test_auto_call_without_reactive(store: StoreType) -> None: + with pytest.raises( + ValueError, + match='^`reactive` must be `True` if `auto_call` is `True`$', + ): + + @store.autorun( + lambda state: state.value, + options=AutorunOptions(reactive=False, auto_call=True), + ) + def _(_: int) -> int: + pytest.fail('This should never be called') + + +call_sequence = [ + # 0 + [ + (IncrementAction()), + ], + # 1 + [ + (IncrementAction()), + (DecrementAction()), + (IncrementByTwoAction()), + (DecrementAction()), + (IncrementAction()), + ], + # 3 + [ + (DecrementAction()), + (DecrementAction()), + ], + # 1 +] + + +def test_no_auto_call_with_initial_call_and_reactive_set( + store: StoreType, + mocker: MockerFixture, +) -> None: + def render(_: int) -> None: ... + + render = mocker.create_autospec(render) + + render_autorun = store.autorun( + lambda state: state.value, + options=AutorunOptions(reactive=True, auto_call=False, initial_call=True), + )(render) + + for actions in call_sequence: + for action in actions: + store.dispatch(action) + render_autorun() + + assert render.mock_calls == [call(0), call(1), call(3), call(1)] + + +def test_no_auto_call_and_no_initial_call_with_reactive_set( + store: StoreType, + mocker: MockerFixture, +) -> None: + def render(_: int) -> None: ... + + render = mocker.create_autospec(render) + + render_autorun = store.autorun( + lambda state: state.value, + options=AutorunOptions(reactive=True, auto_call=False, initial_call=False), + )(render) + + for actions in call_sequence: + for action in actions: + store.dispatch(action) + render_autorun() + + assert render.mock_calls == [call(1), call(3), call(1)] + + +def test_with_auto_call_and_initial_call_and_reactive_set( + store: StoreType, + mocker: MockerFixture, +) -> None: + def render(_: int) -> None: ... + + render = mocker.create_autospec(render) + + render_autorun = store.autorun( + lambda state: state.value, + options=AutorunOptions(reactive=True, auto_call=True, initial_call=True), + )(render) + + for actions in call_sequence: + for action in actions: + store.dispatch(action) + render_autorun() + + assert render.mock_calls == [ + call(0), + call(1), + call(2), + call(1), + call(3), + call(2), + call(3), + call(2), + call(1), + ] diff --git a/tests/test_monitor_fixtures.py b/tests/test_monitor_fixtures.py index ee7baaf..23689a9 100644 --- a/tests/test_monitor_fixtures.py +++ b/tests/test_monitor_fixtures.py @@ -118,8 +118,8 @@ def is_closed() -> None: assert store_snapshot._is_closed # noqa: SLF001 with pytest.raises( RuntimeError, - match='Snapshot context is closed, make sure you are not calling `take` ' - 'after `FinishEvent` is dispatched.', + match='^Snapshot context is closed, make sure you are not calling `take` ' + 'after `FinishEvent` is dispatched.$', ): store_snapshot.take() diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 0440d98..a8ddff8 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -46,7 +46,7 @@ class InvalidType: ... def test_invalid() -> None: with pytest.raises( TypeError, - match=f'Unable to serialize object with type `{InvalidType}`', + match=f'^Unable to serialize object with type `{InvalidType}`$', ): Store.serialize_value(InvalidType()) diff --git a/tests/test_weakref.py b/tests/test_weakref.py index 4bc3a38..b152b6a 100644 --- a/tests/test_weakref.py +++ b/tests/test_weakref.py @@ -117,7 +117,7 @@ def render_with_keep_ref(value: int) -> int: @store.autorun( lambda state: state.value, - options=AutorunOptions(keep_ref=False, initial_run=False), + options=AutorunOptions(keep_ref=False, initial_call=False), ) def render_without_keep_ref(_: int) -> int: pytest.fail('This should never be called') @@ -155,7 +155,7 @@ def test_autorun_method( instance_without_keep_ref = AutorunClass(store_snapshot) store.autorun( lambda state: state.value, - options=AutorunOptions(keep_ref=False, initial_run=False), + options=AutorunOptions(keep_ref=False, initial_call=False), )( instance_without_keep_ref.method_without_keep_ref, )