From 521bb858bec6b89b92b0918141a3728717b74f6f Mon Sep 17 00:00:00 2001 From: Sassan Haradji Date: Thu, 14 Mar 2024 04:46:19 +0400 Subject: [PATCH] feat: add `keep_ref` parameter to subscriptions and autoruns, defaulting to `True`, if set to `False`, the subscription/autorun will not keep a reference to the callback refacotr: general housekeeping --- CHANGELOG.md | 6 +++ poetry.lock | 44 ++++++++-------- pyproject.toml | 8 +-- redux/autorun.py | 123 +++++++++++++++++++++++++++++++------------ redux/basic_types.py | 8 +-- redux/main.py | 28 ++++++---- 6 files changed, 146 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fc790c..fe88af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Version 0.11.0 + +- feat: add `keep_ref` parameter to subscriptions and autoruns, defaulting to `True`, + if set to `False`, the subscription/autorun will not keep a reference to the callback +- refacotr: general housekeeping + ## Version 0.10.7 - fix: autorun now correctly updates its value when the store is updated diff --git a/poetry.lock b/poetry.lock index 4bc7358..2780791 100644 --- a/poetry.lock +++ b/poetry.lock @@ -45,13 +45,13 @@ poetry-plugin = ["poetry (>=1.0,<2.0)"] [[package]] name = "pyright" -version = "1.1.350" +version = "1.1.354" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.350-py3-none-any.whl", hash = "sha256:f1dde6bcefd3c90aedbe9dd1c573e4c1ddbca8c74bf4fa664dd3b1a599ac9a66"}, - {file = "pyright-1.1.350.tar.gz", hash = "sha256:a8ba676de3a3737ea4d8590604da548d4498cc5ee9ee00b1a403c6db987916c6"}, + {file = "pyright-1.1.354-py3-none-any.whl", hash = "sha256:f28d61ae8ae035fc52ded1070e8d9e786051a26a4127bbd7a4ba0399b81b37b5"}, + {file = "pyright-1.1.354.tar.gz", hash = "sha256:b1070dc774ff2e79eb0523fe87f4ba9a90550de7e4b030a2bc9e031864029a1f"}, ] [package.dependencies] @@ -74,28 +74,28 @@ files = [ [[package]] name = "ruff" -version = "0.2.2" +version = "0.3.2" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0a9efb032855ffb3c21f6405751d5e147b0c6b631e3ca3f6b20f917572b97eb6"}, - {file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d450b7fbff85913f866a5384d8912710936e2b96da74541c82c1b458472ddb39"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecd46e3106850a5c26aee114e562c329f9a1fbe9e4821b008c4404f64ff9ce73"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e22676a5b875bd72acd3d11d5fa9075d3a5f53b877fe7b4793e4673499318ba"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1695700d1e25a99d28f7a1636d85bafcc5030bba9d0578c0781ba1790dbcf51c"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b0c232af3d0bd8f521806223723456ffebf8e323bd1e4e82b0befb20ba18388e"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f63d96494eeec2fc70d909393bcd76c69f35334cdbd9e20d089fb3f0640216ca"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a61ea0ff048e06de273b2e45bd72629f470f5da8f71daf09fe481278b175001"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e1439c8f407e4f356470e54cdecdca1bd5439a0673792dbe34a2b0a551a2fe3"}, - {file = "ruff-0.2.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:940de32dc8853eba0f67f7198b3e79bc6ba95c2edbfdfac2144c8235114d6726"}, - {file = "ruff-0.2.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0c126da55c38dd917621552ab430213bdb3273bb10ddb67bc4b761989210eb6e"}, - {file = "ruff-0.2.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3b65494f7e4bed2e74110dac1f0d17dc8e1f42faaa784e7c58a98e335ec83d7e"}, - {file = "ruff-0.2.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ec49be4fe6ddac0503833f3ed8930528e26d1e60ad35c2446da372d16651ce9"}, - {file = "ruff-0.2.2-py3-none-win32.whl", hash = "sha256:d920499b576f6c68295bc04e7b17b6544d9d05f196bb3aac4358792ef6f34325"}, - {file = "ruff-0.2.2-py3-none-win_amd64.whl", hash = "sha256:cc9a91ae137d687f43a44c900e5d95e9617cb37d4c989e462980ba27039d239d"}, - {file = "ruff-0.2.2-py3-none-win_arm64.whl", hash = "sha256:c9d15fc41e6054bfc7200478720570078f0b41c9ae4f010bcc16bd6f4d1aacdd"}, - {file = "ruff-0.2.2.tar.gz", hash = "sha256:e62ed7f36b3068a30ba39193a14274cd706bc486fad521276458022f7bccb31d"}, + {file = "ruff-0.3.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77f2612752e25f730da7421ca5e3147b213dca4f9a0f7e0b534e9562c5441f01"}, + {file = "ruff-0.3.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9966b964b2dd1107797be9ca7195002b874424d1d5472097701ae8f43eadef5d"}, + {file = "ruff-0.3.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b83d17ff166aa0659d1e1deaf9f2f14cbe387293a906de09bc4860717eb2e2da"}, + {file = "ruff-0.3.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb875c6cc87b3703aeda85f01c9aebdce3d217aeaca3c2e52e38077383f7268a"}, + {file = "ruff-0.3.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be75e468a6a86426430373d81c041b7605137a28f7014a72d2fc749e47f572aa"}, + {file = "ruff-0.3.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:967978ac2d4506255e2f52afe70dda023fc602b283e97685c8447d036863a302"}, + {file = "ruff-0.3.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1231eacd4510f73222940727ac927bc5d07667a86b0cbe822024dd00343e77e9"}, + {file = "ruff-0.3.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c6d613b19e9a8021be2ee1d0e27710208d1603b56f47203d0abbde906929a9b"}, + {file = "ruff-0.3.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8439338a6303585d27b66b4626cbde89bb3e50fa3cae86ce52c1db7449330a7"}, + {file = "ruff-0.3.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:de8b480d8379620cbb5ea466a9e53bb467d2fb07c7eca54a4aa8576483c35d36"}, + {file = "ruff-0.3.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b74c3de9103bd35df2bb05d8b2899bf2dbe4efda6474ea9681280648ec4d237d"}, + {file = "ruff-0.3.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f380be9fc15a99765c9cf316b40b9da1f6ad2ab9639e551703e581a5e6da6745"}, + {file = "ruff-0.3.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0ac06a3759c3ab9ef86bbeca665d31ad3aa9a4b1c17684aadb7e61c10baa0df4"}, + {file = "ruff-0.3.2-py3-none-win32.whl", hash = "sha256:9bd640a8f7dd07a0b6901fcebccedadeb1a705a50350fb86b4003b805c81385a"}, + {file = "ruff-0.3.2-py3-none-win_amd64.whl", hash = "sha256:0c1bdd9920cab5707c26c8b3bf33a064a4ca7842d91a99ec0634fec68f9f4037"}, + {file = "ruff-0.3.2-py3-none-win_arm64.whl", hash = "sha256:5f65103b1d76e0d600cabd577b04179ff592064eaa451a70a81085930e907d0b"}, + {file = "ruff-0.3.2.tar.gz", hash = "sha256:fa78ec9418eb1ca3db392811df3376b46471ae93792a81af2d1cbb0e5dcb5142"}, ] [[package]] @@ -139,4 +139,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "8db2e67230573c2f5da7b93b4b75e4f54608c5c5ad6b27b567075c59ac108b97" +content-hash = "b119d5c58b44d6c35d2dd2d5287052e3d9a84fd182c9d2160edd9126182d526c" diff --git a/pyproject.toml b/pyproject.toml index 3e6ef73..743c8da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-redux" -version = "0.10.7" +version = "0.11.0" description = "Redux implementation for Python" authors = ["Sassan Haradji "] license = "Apache-2.0" @@ -17,8 +17,8 @@ optional = true [tool.poetry.group.dev.dependencies] poethepoet = "^0.24.4" -pyright = "^1.1.350" -ruff = "^0.2.2" +pyright = "^1.1.354" +ruff = "^0.3.2" [build-system] requires = ["poetry-core"] @@ -29,7 +29,7 @@ demo = "demo:main" todo_demo = "todo_demo:main" [tool.poe.tasks] -lint = "ruff . --unsafe-fixes" +lint = "ruff check . --unsafe-fixes" typecheck = "pyright -p pyproject.toml ." sanity = ["typecheck", "lint"] diff --git a/redux/autorun.py b/redux/autorun.py index e198566..e206c8a 100644 --- a/redux/autorun.py +++ b/redux/autorun.py @@ -1,10 +1,11 @@ # ruff: noqa: D100, D101, D102, D103, D104, D105, D107 from __future__ import annotations +import inspect import weakref +from asyncio import iscoroutinefunction from inspect import signature -from types import MethodType -from typing import TYPE_CHECKING, Any, Callable, Generic, cast +from typing import TYPE_CHECKING, Any, Callable, Coroutine, Generic, cast from redux.basic_types import ( Action, @@ -17,6 +18,8 @@ ) if TYPE_CHECKING: + from types import MethodType + from redux.main import Store @@ -43,7 +46,12 @@ def __init__( # noqa: PLR0913 self._store = store self._selector = selector self._comparator = comparator - self._func = func + if options.keep_ref: + self._func = func + elif inspect.ismethod(func): + self._func = weakref.WeakMethod(func) + else: + self._func = weakref.ref(func) self._options = options self._last_selector_result: SelectorOutput | None = None @@ -51,22 +59,91 @@ def __init__( # noqa: PLR0913 ComparatorOutput, object(), ) - self._latest_value: AutorunOriginalReturnType | None = options.default_value + self._latest_value: AutorunOriginalReturnType = options.default_value self._subscriptions: set[ Callable[[AutorunOriginalReturnType], Any] | weakref.ref[Callable[[AutorunOriginalReturnType], Any]] ] = set() + self._immediate_run = ( + not iscoroutinefunction(func) + if options.subscribers_immediate_run is None + else options.subscribers_immediate_run + ) if self._options.initial_run and store._state is not None: # noqa: SLF001 - self.check_and_call(store._state) # noqa: SLF001 + self._check_and_call(store._state) # noqa: SLF001 - store.subscribe(self.check_and_call) + store.subscribe(self._check_and_call) - def check_and_call(self: Autorun, state: State) -> None: + def inform_subscribers( + self: Autorun[ + State, + Action, + Event, + SelectorOutput, + ComparatorOutput, + AutorunOriginalReturnType, + ], + ) -> None: + for subscriber_ in self._subscriptions.copy(): + if isinstance(subscriber_, weakref.ref): + subscriber = subscriber_() + if subscriber is None: + self._subscriptions.discard(subscriber_) + continue + else: + subscriber = subscriber_ + subscriber(self._latest_value) + + def call_func( + self: Autorun[ + State, + Action, + Event, + SelectorOutput, + ComparatorOutput, + AutorunOriginalReturnType, + ], + selector_result: SelectorOutput, + previous_result: SelectorOutput | None, + func: Callable[ + [SelectorOutput, SelectorOutput], + AutorunOriginalReturnType, + ] + | Callable[[SelectorOutput], AutorunOriginalReturnType] + | MethodType, + ) -> AutorunOriginalReturnType: + if len(signature(func).parameters) == 1: + return cast( + Callable[[SelectorOutput], AutorunOriginalReturnType], + func, + )(selector_result) + return cast( + Callable[ + [SelectorOutput, SelectorOutput | None], + AutorunOriginalReturnType, + ], + func, + )(selector_result, previous_result) + + def _check_and_call( + self: Autorun[ + State, + Action, + Event, + SelectorOutput, + ComparatorOutput, + AutorunOriginalReturnType, + ], + state: State, + ) -> None: try: selector_result = self._selector(state) except AttributeError: return + func = self._func() if isinstance(self._func, weakref.ref) else self._func + if func is None: + return if self._comparator is None: comparator_result = cast(ComparatorOutput, selector_result) else: @@ -75,31 +152,11 @@ def check_and_call(self: Autorun, state: State) -> None: previous_result = self._last_selector_result self._last_selector_result = selector_result self._last_comparator_result = comparator_result - if len(signature(self._func).parameters) == 1: - self._latest_value = cast( - Callable[[SelectorOutput], AutorunOriginalReturnType], - self._func, - )(selector_result) + self._latest_value = self.call_func(selector_result, previous_result, func) + if self._immediate_run: + self.inform_subscribers() else: - self._latest_value = cast( - Callable[ - [SelectorOutput, SelectorOutput | None], - AutorunOriginalReturnType, - ], - self._func, - )( - selector_result, - previous_result, - ) - for subscriber_ in self._subscriptions.copy(): - if isinstance(subscriber_, weakref.ref): - subscriber = subscriber_() - if subscriber is None: - self._subscriptions.discard(subscriber_) - continue - else: - subscriber = subscriber_ - subscriber(self._latest_value) + self._store._create_task(cast(Coroutine, self._latest_value)) # noqa: SLF001 def __call__( self: Autorun[ @@ -112,7 +169,7 @@ def __call__( ], ) -> AutorunOriginalReturnType: if self._store._state is not None: # noqa: SLF001 - self.check_and_call(self._store._state) # noqa: SLF001 + self._check_and_call(self._store._state) # noqa: SLF001 return cast(AutorunOriginalReturnType, self._latest_value) def __repr__( @@ -161,7 +218,7 @@ def subscribe( keep_ref = self._options.subscribers_keep_ref if keep_ref: callback_ref = callback - elif isinstance(callback, MethodType): + elif inspect.ismethod(callback): callback_ref = weakref.WeakMethod(callback) else: callback_ref = weakref.ref(callback) diff --git a/redux/basic_types.py b/redux/basic_types.py index f90ad65..49956f9 100644 --- a/redux/basic_types.py +++ b/redux/basic_types.py @@ -1,7 +1,7 @@ # ruff: noqa: A003, D100, D101, D102, D103, D104, D105, D107 from __future__ import annotations -from typing import Any, Callable, Generic, Protocol, TypeAlias, TypeGuard +from typing import Any, Callable, Coroutine, Generic, Protocol, TypeAlias, TypeGuard from immutable import Immutable from typing_extensions import TypeVar @@ -16,7 +16,7 @@ class BaseEvent(Immutable): class EventSubscriptionOptions(Immutable): - run_async: bool = True + immediate_run: bool = False keep_ref: bool = True @@ -85,12 +85,14 @@ class CreateStoreOptions(Immutable): scheduler: Scheduler | None = None action_middleware: Callable[[BaseAction], Any] | None = None event_middleware: Callable[[BaseEvent], Any] | None = None + task_creator: Callable[[Coroutine], Any] | None = None class AutorunOptions(Immutable, Generic[AutorunOriginalReturnType]): default_value: AutorunOriginalReturnType | None = None initial_run: bool = True - subscribers_immediate_run: bool = True + keep_ref: bool = True + subscribers_immediate_run: bool | None = None subscribers_keep_ref: bool = True diff --git a/redux/main.py b/redux/main.py index e920d86..a55513c 100644 --- a/redux/main.py +++ b/redux/main.py @@ -1,14 +1,15 @@ # ruff: noqa: D100, D101, D102, D103, D104, D105, D107 from __future__ import annotations +import inspect import queue import threading import weakref +from asyncio import create_task, iscoroutine from collections import defaultdict from inspect import signature from threading import Lock -from types import MethodType -from typing import Any, Callable, Generic, cast +from typing import Any, Callable, Coroutine, Generic, cast from redux.autorun import Autorun from redux.basic_types import ( @@ -41,9 +42,11 @@ class _SideEffectRunnerThread(threading.Thread, Generic[Event]): def __init__( self: _SideEffectRunnerThread[Event], task_queue: queue.Queue[tuple[EventHandler[Event], Event] | None], + task_creator: Callable[[Coroutine], Any], ) -> None: super().__init__() self.task_queue = task_queue + self.create_task = task_creator def run(self: _SideEffectRunnerThread[Event]) -> None: while True: @@ -55,9 +58,11 @@ def run(self: _SideEffectRunnerThread[Event]) -> None: try: event_handler, event = task if len(signature(event_handler).parameters) == 1: - cast(Callable[[Event], Any], event_handler)(event) + result = cast(Callable[[Event], Any], event_handler)(event) else: - cast(Callable[[], Any], event_handler)() + result = cast(Callable[[], Any], event_handler)() + if iscoroutine(result): + self.create_task(result) finally: self.task_queue.task_done() @@ -70,6 +75,9 @@ def __init__( ) -> None: self.store_options = options or CreateStoreOptions() self.reducer = reducer + self._create_task: Callable[[Coroutine], Any] = ( + self.store_options.task_creator or create_task + ) self._state: State | None = None self._listeners: set[ @@ -92,7 +100,7 @@ def __init__( tuple[EventHandler[Event], Event] | None ]() workers = [ - _SideEffectRunnerThread(self._event_handlers_queue) + _SideEffectRunnerThread(self._event_handlers_queue, self._create_task) for _ in range(self.store_options.threads) ] for worker in workers: @@ -135,7 +143,9 @@ def _run_actions(self: Store[State, Action, Event]) -> None: continue else: listener = listener_ - listener(self._state) + result = listener(self._state) + if iscoroutine(result): + self._create_task(result) def _run_event_handlers(self: Store[State, Action, Event]) -> None: event = self._events.pop(0) @@ -149,7 +159,7 @@ def _run_event_handlers(self: Store[State, Action, Event]) -> None: continue else: event_handler = event_handler_ - if options.run_async: + 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) @@ -201,7 +211,7 @@ def subscribe( ) -> Callable[[], None]: if keep_ref: listener_ref = listener - elif isinstance(listener, MethodType): + elif inspect.ismethod(listener): listener_ref = weakref.WeakMethod(listener) else: listener_ref = weakref.ref(listener) @@ -222,7 +232,7 @@ def subscribe_event( if subscription_options.keep_ref: handler_ref = handler - elif isinstance(handler, MethodType): + elif inspect.ismethod(handler): handler_ref = weakref.WeakMethod(handler) else: handler_ref = weakref.ref(handler)