diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f86e19..032daad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Version 0.12.5 + +- refactor: add cleanup to `FinishEvent` handler to clean workers, listeners, subscriptions, + autoruns, etc +- refactor: `TaskCreator` add `TaskCreatorCallback` protocols +- refactor: `Store._create_task` now has a callback parameter to report the created + task +- refactor: move serialization methods and side_effect_runner class to separate + files + ## Version 0.12.4 - fix: serialization class methods of `Store` use `cls` instead of `Store` for the diff --git a/README.md b/README.md index 450ef75..440a483 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ -# 🚀 Python Redux +# 🎛️ Python Redux +[![image](https://img.shields.io/pypi/v/python-redux.svg)](https://pypi.python.org/pypi/python-redux) +[![image](https://img.shields.io/pypi/l/python-redux.svg)](https://github.com/sassanh/python-redux/LICENSE) +[![image](https://img.shields.io/pypi/pyversions/python-redux.svg)](https://pypi.python.org/pypi/python-redux) +[![Actions status](https://github.com/sassanh/python-redux/workflows/CI/CD/badge.svg)](https://github.com/sassanh/python-redux/actions) [![codecov](https://codecov.io/gh/sassanh/python-redux/graph/badge.svg?token=4F3EWZRLCL)](https://codecov.io/gh/sassanh/python-redux) ## 🌟 Overview diff --git a/poetry.lock b/poetry.lock index 3258d3f..d15b978 100644 --- a/poetry.lock +++ b/poetry.lock @@ -306,4 +306,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "87a68a307610d1b8bea7f8f993a17629f50afd5803333f35ea69d90bb4146278" +content-hash = "df567d43e200240dce999516151d59522c38740cad7e5b6906fc2db812fbe41b" diff --git a/pyproject.toml b/pyproject.toml index f81a152..f01df7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-redux" -version = "0.12.4" +version = "0.12.5" description = "Redux implementation for Python" authors = ["Sassan Haradji "] license = "Apache-2.0" @@ -11,7 +11,6 @@ packages = [{ include = "redux" }] python = "^3.11" python-immutable = "^1.0.5" typing-extensions = "^4.9.0" -pytest-timeout = "^2.3.1" [tool.poetry.group.dev] optional = true @@ -22,6 +21,7 @@ pyright = "^1.1.354" ruff = "^0.3.3" pytest = "^8.1.1" pytest-cov = "^4.1.0" +pytest-timeout = "^2.3.1" [build-system] requires = ["poetry-core"] @@ -63,7 +63,7 @@ exclude = ['typings'] [tool.pytest.ini_options] log_cli = 1 log_cli_level = 'ERROR' -timeout = 4 +timeout = 1 [tool.coverage.report] exclude_also = ["if TYPE_CHECKING:"] diff --git a/redux/autorun.py b/redux/autorun.py index 1d79753..6c70349 100644 --- a/redux/autorun.py +++ b/redux/autorun.py @@ -3,9 +3,9 @@ import inspect import weakref -from asyncio import iscoroutinefunction +from asyncio import Task, iscoroutine from inspect import signature -from typing import TYPE_CHECKING, Any, Callable, Coroutine, Generic, cast +from typing import TYPE_CHECKING, Any, Callable, Generic, cast from redux.basic_types import ( Action, @@ -121,6 +121,20 @@ def call_func( func, )(selector_result, previous_result) + def _task_callback( + self: Autorun[ + State, + Action, + Event, + SelectorOutput, + ComparatorOutput, + AutorunOriginalReturnType, + ], + task: Task, + ) -> None: + task.add_done_callback(lambda _: self.inform_subscribers()) + self._latest_value = cast(AutorunOriginalReturnType, task) + def _check_and_call( self: Autorun[ State, @@ -154,12 +168,11 @@ def _check_and_call( previous_result, func, ) - if iscoroutinefunction(func): - task = self._store._async_loop.create_task( # noqa: SLF001 - cast(Coroutine, self._latest_value), + if iscoroutine(self._latest_value): + self._store._create_task( # noqa: SLF001 + self._latest_value, + callback=self._task_callback, ) - task.add_done_callback(lambda _: self.inform_subscribers()) - self._latest_value = cast(AutorunOriginalReturnType, task) else: self.inform_subscribers() @@ -234,12 +247,6 @@ def subscribe( callback(self.value) def unsubscribe() -> None: - callback = ( - callback_ref() - if isinstance(callback_ref, weakref.ref) - else callback_ref - ) - if callback is not None: - self._subscriptions.discard(callback) + self._subscriptions.discard(callback_ref) return unsubscribe diff --git a/redux/basic_types.py b/redux/basic_types.py index 5dfc1ad..7b4c9d4 100644 --- a/redux/basic_types.py +++ b/redux/basic_types.py @@ -2,13 +2,22 @@ from __future__ import annotations from types import NoneType -from typing import TYPE_CHECKING, Any, Callable, Generic, Protocol, TypeAlias, TypeGuard +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Coroutine, + Generic, + Protocol, + TypeAlias, + TypeGuard, +) from immutable import Immutable from typing_extensions import TypeVar if TYPE_CHECKING: - import asyncio + from asyncio import Task class BaseAction(Immutable): ... @@ -77,13 +86,26 @@ class Scheduler(Protocol): def __call__(self: Scheduler, callback: Callable, *, interval: bool) -> None: ... +class TaskCreatorCallback(Protocol): + def __call__(self: TaskCreatorCallback, task: Task) -> None: ... + + +class TaskCreator(Protocol): + def __call__( + self: TaskCreator, + coro: Coroutine, + *, + callback: TaskCreatorCallback | None = None, + ) -> None: ... + + class CreateStoreOptions(Immutable): auto_init: bool = False threads: int = 5 scheduler: Scheduler | None = None action_middleware: Callable[[BaseAction], Any] | None = None event_middleware: Callable[[BaseEvent], Any] | None = None - async_loop: asyncio.AbstractEventLoop | None = None + task_creator: TaskCreator | None = None class AutorunOptions(Immutable, Generic[AutorunOriginalReturnType]): diff --git a/redux/main.py b/redux/main.py index 6c81223..d440917 100644 --- a/redux/main.py +++ b/redux/main.py @@ -2,20 +2,15 @@ from __future__ import annotations -import dataclasses import inspect import queue -import threading import weakref -from asyncio import AbstractEventLoop, get_event_loop, iscoroutinefunction +from asyncio import get_event_loop, iscoroutine from collections import defaultdict from inspect import signature from threading import Lock -from types import NoneType from typing import Any, Callable, Coroutine, Generic, cast -from immutable import Immutable, is_immutable - from redux.autorun import Autorun from redux.basic_types import ( Action, @@ -39,45 +34,25 @@ SelectorOutput, SnapshotAtom, State, + TaskCreator, + TaskCreatorCallback, is_complete_reducer_result, is_state_reducer_result, ) +from redux.serialization_mixin import SerializationMixin +from redux.side_effect_runner import SideEffectRunnerThread -class _SideEffectRunnerThread(threading.Thread, Generic[Event]): - def __init__( - self: _SideEffectRunnerThread[Event], - *, - task_queue: queue.Queue[tuple[EventHandler[Event], Event] | None], - async_loop: AbstractEventLoop, - ) -> None: - super().__init__() - self.task_queue = task_queue - self.async_loop = async_loop - - def create_task(self: _SideEffectRunnerThread[Event], coro: Coroutine) -> None: - self.async_loop.call_soon_threadsafe(lambda: self.async_loop.create_task(coro)) - - def run(self: _SideEffectRunnerThread[Event]) -> None: - while True: - task = self.task_queue.get() - if task is None: - self.task_queue.task_done() - break - - try: - event_handler, event = task - if len(signature(event_handler).parameters) == 1: - result = cast(Callable[[Event], Any], event_handler)(event) - else: - result = cast(Callable[[], Any], event_handler)() - if iscoroutinefunction(event_handler): - self.create_task(result) - finally: - self.task_queue.task_done() +def _default_task_creator( + coro: Coroutine, + callback: TaskCreatorCallback | None = None, +) -> None: + result = get_event_loop().create_task(coro) + if callback: + callback(result) -class Store(Generic[State, Action, Event]): +class Store(Generic[State, Action, Event], SerializationMixin): """Redux store for managing state and side effects.""" def __init__( @@ -88,7 +63,9 @@ def __init__( """Create a new store.""" self.store_options = options or CreateStoreOptions() self.reducer = reducer - self._async_loop = self.store_options.async_loop or get_event_loop() + self._create_task: TaskCreator = ( + self.store_options.task_creator or _default_task_creator + ) self._state: State | None = None self._listeners: set[ @@ -110,14 +87,14 @@ def __init__( self._event_handlers_queue = queue.Queue[ tuple[EventHandler[Event], Event] | None ]() - workers = [ - _SideEffectRunnerThread( + self._workers = [ + SideEffectRunnerThread( task_queue=self._event_handlers_queue, - async_loop=self._async_loop, + task_creator=self._create_task, ) for _ in range(self.store_options.threads) ] - for worker in workers: + for worker in self._workers: worker.start() self._is_running = Lock() @@ -158,8 +135,8 @@ def _run_actions(self: Store[State, Action, Event]) -> None: else: listener = listener_ result = listener(self._state) - if iscoroutinefunction(listener): - self._async_loop.create_task(result) + if iscoroutine(result): + self._create_task(result) def _run_event_handlers(self: Store[State, Action, Event]) -> None: event = self._events.pop(0) @@ -175,10 +152,13 @@ def _run_event_handlers(self: Store[State, Action, Event]) -> None: event_handler = event_handler_ if not options.immediate_run: self._event_handlers_queue.put((event_handler, event)) - elif len(signature(event_handler).parameters) == 1: - cast(Callable[[Event], Any], event_handler)(event) else: - cast(Callable[[], Any], event_handler)() + if len(signature(event_handler).parameters) == 1: + result = cast(Callable[[Event], Any], event_handler)(event) + else: + result = cast(Callable[[], Any], event_handler)() + if iscoroutine(result): + self._create_task(result) def run(self: Store[State, Action, Event]) -> None: """Run the store.""" @@ -189,6 +169,12 @@ def run(self: Store[State, Action, Event]) -> None: if len(self._events) > 0: self._run_event_handlers() + if not any(i.is_alive() for i in self._workers): + for worker in self._workers: + worker.join() + self._workers.clear() + self._listeners.clear() + self._event_handlers.clear() def dispatch( self: Store[State, Action, Event], @@ -258,15 +244,15 @@ def subscribe_event( self._event_handlers[cast(type[Event], event_type)].add( (handler_ref, subscription_options), ) - return lambda: self._event_handlers[cast(type[Event], event_type)].discard( - (handler_ref, subscription_options), - ) - def _handle_finish_event( - self: Store[State, Action, Event], - finish_event: Event, - ) -> None: - _ = finish_event + def unsubscribe() -> None: + self._event_handlers[cast(type[Event], event_type)].discard( + (handler_ref, subscription_options), + ) + + return unsubscribe + + def _handle_finish_event(self: Store[State, Action, Event]) -> None: for _ in range(self.store_options.threads): self._event_handlers_queue.put(None) @@ -301,28 +287,3 @@ def decorator( def snapshot(self: Store[State, Action, Event]) -> SnapshotAtom: """Return a snapshot of the current state of the store.""" return self.serialize_value(self._state) - - @classmethod - def serialize_value(cls: type[Store], obj: object | type) -> SnapshotAtom: - """Serialize a value to a snapshot atom.""" - if isinstance(obj, (int, float, str, bool, NoneType)): - return obj - if callable(obj): - return cls.serialize_value(obj()) - if isinstance(obj, (list, tuple)): - 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)}`.' - raise TypeError(msg) - - @classmethod - def _serialize_dataclass_to_dict( - cls: type[Store], - obj: Immutable, - ) -> dict[str, Any]: - result = {} - for field in dataclasses.fields(obj): - value = cls.serialize_value(getattr(obj, field.name)) - result[field.name] = value - return result diff --git a/redux/serialization_mixin.py b/redux/serialization_mixin.py new file mode 100644 index 0000000..a13a554 --- /dev/null +++ b/redux/serialization_mixin.py @@ -0,0 +1,44 @@ +"""Mixin for serialization.""" + +from __future__ import annotations + +import dataclasses +from types import NoneType +from typing import TYPE_CHECKING, Any + +from immutable import Immutable, is_immutable + +if TYPE_CHECKING: + from redux.basic_types import SnapshotAtom + + +class SerializationMixin: + """Mixin for serialization.""" + + @classmethod + def serialize_value( + cls: type[SerializationMixin], + obj: object | type, + ) -> SnapshotAtom: + """Serialize a value to a snapshot atom.""" + if isinstance(obj, (int, float, str, bool, NoneType)): + return obj + if callable(obj): + return cls.serialize_value(obj()) + if isinstance(obj, (list, tuple)): + 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)}`.' + raise TypeError(msg) + + @classmethod + def _serialize_dataclass_to_dict( + cls: type[SerializationMixin], + obj: Immutable, + ) -> dict[str, Any]: + result = {} + for field in dataclasses.fields(obj): + value = cls.serialize_value(getattr(obj, field.name)) + result[field.name] = value + return result diff --git a/redux/side_effect_runner.py b/redux/side_effect_runner.py new file mode 100644 index 0000000..d30008a --- /dev/null +++ b/redux/side_effect_runner.py @@ -0,0 +1,47 @@ +"""Redux store for managing state and side effects.""" + +from __future__ import annotations + +import threading +from asyncio import iscoroutine +from inspect import signature +from typing import TYPE_CHECKING, Any, Callable, Generic, cast + +from redux.basic_types import Event, EventHandler, TaskCreator + +if TYPE_CHECKING: + import queue + + +class SideEffectRunnerThread(threading.Thread, Generic[Event]): + """Thread for running side effects.""" + + def __init__( + self: SideEffectRunnerThread[Event], + *, + task_queue: queue.Queue[tuple[EventHandler[Event], Event] | None], + task_creator: TaskCreator, + ) -> None: + """Initialize the side effect runner thread.""" + super().__init__() + self.task_queue = task_queue + self.create_task = task_creator + + def run(self: SideEffectRunnerThread[Event]) -> None: + """Run the side effect runner thread.""" + while True: + task = self.task_queue.get() + if task is None: + self.task_queue.task_done() + break + + try: + event_handler, event = task + if len(signature(event_handler).parameters) == 1: + result = cast(Callable[[Event], Any], event_handler)(event) + else: + result = cast(Callable[[], Any], event_handler)() + if iscoroutine(result): + self.create_task(result) + finally: + self.task_queue.task_done() diff --git a/tests/test_async.py b/tests/test_async.py index 0707e29..231c3cd 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -2,8 +2,9 @@ from __future__ import annotations import asyncio +import threading from dataclasses import replace -from typing import Generator +from typing import Callable, Coroutine, Generator import pytest from immutable import Immutable @@ -12,6 +13,7 @@ BaseAction, CompleteReducerResult, CreateStoreOptions, + EventSubscriptionOptions, FinishAction, FinishEvent, InitAction, @@ -50,32 +52,83 @@ def reducer( return state +class LoopThread(threading.Thread): + def __init__(self: LoopThread) -> None: + super().__init__() + self.loop = asyncio.new_event_loop() + + def run(self: LoopThread) -> None: + self.loop.run_forever() + + def stop(self: LoopThread) -> None: + self.loop.call_soon_threadsafe(self.loop.stop) + + @pytest.fixture() -def loop() -> asyncio.AbstractEventLoop: - return asyncio.get_event_loop() +def loop() -> LoopThread: + loop_thread = LoopThread() + loop_thread.start() + return loop_thread Action = IncrementAction | SetMirroredValueAction | InitAction | FinishAction +StoreType = Store[StateType, Action, FinishEvent] @pytest.fixture() def store( - loop: asyncio.AbstractEventLoop, -) -> Generator[Store[StateType, Action, FinishEvent], None, None]: + loop: LoopThread, +) -> Generator[StoreType, None, None]: + def _create_task_with_callback( + coro: Coroutine, + callback: Callable[[asyncio.Task], None] | None = None, + ) -> None: + def create_task_with_callback() -> None: + task = loop.loop.create_task(coro) + if callback: + callback(task) + + loop.loop.call_soon_threadsafe(create_task_with_callback) + store = Store( reducer, - options=CreateStoreOptions(auto_init=True, async_loop=loop), + options=CreateStoreOptions( + auto_init=True, + task_creator=_create_task_with_callback, + ), ) yield store - for _i in range(INCREMENTS): + for i in range(INCREMENTS): + _ = i store.dispatch(IncrementAction()) - store.dispatch(FinishAction()) - loop.run_forever() + + +def test_create_task( + store: StoreType, + loop: LoopThread, +) -> None: + async def task(value: int) -> int: + await asyncio.sleep(0.5) + return value + + def done(task: asyncio.Task) -> None: + assert task.result() == 1 + store.dispatch(FinishAction()) + + def callback(task: asyncio.Task) -> None: + task.add_done_callback(done) + + store._create_task(task(1), callback=callback) # noqa: SLF001 + + def finish() -> None: + loop.stop() + + store.subscribe_event(FinishEvent, finish) def test_autorun( - store: Store[StateType, Action, FinishEvent], - loop: asyncio.AbstractEventLoop, + store: StoreType, + loop: LoopThread, ) -> None: @store.autorun(lambda state: state.value) async def _(value: int) -> int: @@ -90,27 +143,51 @@ async def _(value: int) -> int: async def _(mirrored_value: int) -> None: if mirrored_value < INCREMENTS: return - loop.call_soon_threadsafe(loop.stop) + store.dispatch(FinishAction()) + + async def finish() -> None: + loop.stop() + + store.subscribe_event(FinishEvent, finish) + store.subscribe_event(FinishEvent, finish) def test_subscription( - store: Store[StateType, Action, FinishEvent], - loop: asyncio.AbstractEventLoop, + store: StoreType, + loop: LoopThread, ) -> None: async def render(state: StateType) -> None: - await asyncio.sleep(0.1) if state.value == INCREMENTS: - loop.call_soon_threadsafe(loop.stop) + unsubscribe() + store.dispatch(FinishAction()) + loop.stop() - store.subscribe(render) + unsubscribe = store.subscribe(render) def test_event_subscription( - store: Store[StateType, Action, FinishEvent], - loop: asyncio.AbstractEventLoop, + store: StoreType, + loop: LoopThread, ) -> None: async def finish() -> None: await asyncio.sleep(0.1) - loop.call_soon_threadsafe(loop.stop) + loop.stop() store.subscribe_event(FinishEvent, finish) + store.dispatch(FinishAction()) + + +def test_immediate_event_subscription( + store: StoreType, + loop: LoopThread, +) -> None: + async def finish() -> None: + await asyncio.sleep(0.1) + loop.stop() + + store.subscribe_event( + FinishEvent, + finish, + options=EventSubscriptionOptions(immediate_run=True), + ) + store.dispatch(FinishAction()) diff --git a/tests/test_autorun.py b/tests/test_autorun.py index d96aebe..938a69e 100644 --- a/tests/test_autorun.py +++ b/tests/test_autorun.py @@ -10,7 +10,6 @@ from redux.basic_types import ( BaseAction, - BaseEvent, CompleteReducerResult, CreateStoreOptions, FinishAction, @@ -55,8 +54,11 @@ def reducer( return state +StoreType = Store[StateType, Action, FinishEvent] + + @pytest.fixture() -def store() -> Generator[Store[StateType, Action, FinishEvent], None, None]: +def store() -> Generator[StoreType, None, None]: store = Store(reducer, options=CreateStoreOptions(auto_init=True)) yield store store.dispatch(IncrementAction()) @@ -65,10 +67,7 @@ def store() -> Generator[Store[StateType, Action, FinishEvent], None, None]: store.dispatch(FinishAction()) -def test_general( - store_snapshot: StoreSnapshotContext, - store: Store[StateType, Action, BaseEvent], -) -> None: +def test_general(store_snapshot: StoreSnapshotContext, store: StoreType) -> None: store_snapshot.set_store(store) @store.autorun(lambda state: state.value) @@ -79,7 +78,7 @@ def _(value: int) -> int: def test_ignore_attribute_error_in_selector( store_snapshot: StoreSnapshotContext, - store: Store[StateType, Action, BaseEvent], + store: StoreType, ) -> None: store_snapshot.set_store(store) @@ -90,7 +89,7 @@ def _(_: int) -> int: def test_ignore_attribute_error_in_comparator( store_snapshot: StoreSnapshotContext, - store: Store[StateType, Action, BaseEvent], + store: StoreType, ) -> None: store_snapshot.set_store(store) @@ -102,10 +101,7 @@ def _(_: int) -> int: pytest.fail('This should never be called') -def test_with_old_value( - store_snapshot: StoreSnapshotContext, - store: Store[StateType, Action, BaseEvent], -) -> None: +def test_with_old_value(store_snapshot: StoreSnapshotContext, store: StoreType) -> None: store_snapshot.set_store(store) @store.autorun(lambda state: state.value) @@ -116,7 +112,7 @@ def _(value: int, old_value: int | None) -> int: def test_with_comparator( store_snapshot: StoreSnapshotContext, - store: Store[StateType, Action, BaseEvent], + store: StoreType, ) -> None: store_snapshot.set_store(store) @@ -131,7 +127,7 @@ def _(value: int) -> int: def test_with_comparator_and_old_value( store_snapshot: StoreSnapshotContext, - store: Store[StateType, Action, BaseEvent], + store: StoreType, ) -> None: store_snapshot.set_store(store) @@ -144,10 +140,7 @@ def _(value: int, old_value: int | None) -> int: return value - (old_value or 0) -def test_value_property( - store_snapshot: StoreSnapshotContext, - store: Store[StateType, Action, BaseEvent], -) -> None: +def test_value_property(store_snapshot: StoreSnapshotContext, store: StoreType) -> None: store_snapshot.set_store(store) @store.autorun(lambda state: state.value) @@ -164,10 +157,7 @@ def check(_: int) -> None: render.subscribe(check) -def test_callability( - store_snapshot: StoreSnapshotContext, - store: Store[StateType, Action, BaseEvent], -) -> None: +def test_callability(store_snapshot: StoreSnapshotContext, store: StoreType) -> None: store_snapshot.set_store(store) @store.autorun(lambda state: state.value) @@ -181,10 +171,7 @@ def check(state: StateType) -> None: store.subscribe(check) -def test_subscription( - store_snapshot: StoreSnapshotContext, - store: Store[StateType, Action, BaseEvent], -) -> None: +def test_subscription(store_snapshot: StoreSnapshotContext, store: StoreType) -> None: store_snapshot.set_store(store) @store.autorun(lambda state: state.value) @@ -197,10 +184,7 @@ def reaction(_: int) -> None: render.subscribe(reaction, initial_run=True) -def test_unsubscription( - store_snapshot: StoreSnapshotContext, - store: Store[StateType, Action, BaseEvent], -) -> None: +def test_unsubscription(store_snapshot: StoreSnapshotContext, store: StoreType) -> None: store_snapshot.set_store(store) @store.autorun(lambda state: state.value) @@ -214,10 +198,7 @@ def reaction(_: int) -> None: unsubscribe() -def test_repr( - store_snapshot: StoreSnapshotContext, - store: Store[StateType, Action, BaseEvent], -) -> None: +def test_repr(store_snapshot: StoreSnapshotContext, store: StoreType) -> None: store_snapshot.set_store(store) @store.autorun(lambda state: state.value) diff --git a/tests/test_features.py b/tests/test_features.py index beca477..e088538 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -152,12 +152,17 @@ def event_handler(event: SleepEvent) -> None: def event_handler_without_parameter() -> None: time.sleep(0.1) + def never_called_event_handler() -> None: + pytest.fail('This should never be called') + store.subscribe_event(SleepEvent, event_handler) store.subscribe_event( SleepEvent, event_handler_without_parameter, options=EventSubscriptionOptions(immediate_run=True), ) + unsubscribe = store.subscribe_event(PrintEvent, never_called_event_handler) + unsubscribe() # Autorun # -------