Skip to content

Commit

Permalink
feat(autorun): add auto_call and reactive options to autorun to c…
Browse files Browse the repository at this point in the history
…ontrol 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.
  • Loading branch information
sassanh committed May 4, 2024
1 parent 8187b6b commit 58343ce
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 12 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 15 additions & 4 deletions redux/autorun.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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[
Expand Down Expand Up @@ -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
Expand All @@ -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()

Expand All @@ -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__(
Expand Down
4 changes: 3 additions & 1 deletion redux/basic_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion redux/serialization_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 122 additions & 1 deletion tests/test_autorun.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,6 +22,8 @@
from redux.main import Store

if TYPE_CHECKING:
from pytest_mock import MockerFixture

from redux_pytest.fixtures import StoreSnapshot


Expand All @@ -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(
Expand All @@ -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)

Expand Down Expand Up @@ -189,3 +201,112 @@ def render(value: int) -> int:
r'.*\(func: <function test_repr\.<locals>\.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),
]
4 changes: 2 additions & 2 deletions tests/test_monitor_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion tests/test_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
4 changes: 2 additions & 2 deletions tests/test_weakref.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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,
)
Expand Down

0 comments on commit 58343ce

Please sign in to comment.