Skip to content

Commit 4958ceb

Browse files
committed
refactor: add cleanup to FinishEvent handler to clean workers, listeners, subscriptions, autoruns, etc
1 parent c892588 commit 4958ceb

12 files changed

+315
-157
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## Version 0.12.5
4+
5+
- refactor: add cleanup to `FinishEvent` handler to clean workers, listeners, subscriptions,
6+
autoruns, etc
7+
- refactor: `TaskCreator` add `TaskCreatorCallback` protocols
8+
- refactor: `Store._create_task` now has a callback parameter to report the created
9+
task
10+
- refactor: move serialization methods and side_effect_runner class to separate
11+
files
12+
313
## Version 0.12.4
414

515
- fix: serialization class methods of `Store` use `cls` instead of `Store` for the

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
# 🚀 Python Redux
1+
# 🎛️ Python Redux
22

3+
[![image](https://img.shields.io/pypi/v/python-redux.svg)](https://pypi.python.org/pypi/python-redux)
4+
[![image](https://img.shields.io/pypi/l/python-redux.svg)](https://github.com/sassanh/python-redux/LICENSE)
5+
[![image](https://img.shields.io/pypi/pyversions/python-redux.svg)](https://pypi.python.org/pypi/python-redux)
6+
[![Actions status](https://github.com/sassanh/python-redux/workflows/CI/CD/badge.svg)](https://github.com/sassanh/python-redux/actions)
37
[![codecov](https://codecov.io/gh/sassanh/python-redux/graph/badge.svg?token=4F3EWZRLCL)](https://codecov.io/gh/sassanh/python-redux)
48

59
## 🌟 Overview

poetry.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "python-redux"
3-
version = "0.12.4"
3+
version = "0.12.5"
44
description = "Redux implementation for Python"
55
authors = ["Sassan Haradji <[email protected]>"]
66
license = "Apache-2.0"
@@ -11,7 +11,6 @@ packages = [{ include = "redux" }]
1111
python = "^3.11"
1212
python-immutable = "^1.0.5"
1313
typing-extensions = "^4.9.0"
14-
pytest-timeout = "^2.3.1"
1514

1615
[tool.poetry.group.dev]
1716
optional = true
@@ -22,6 +21,7 @@ pyright = "^1.1.354"
2221
ruff = "^0.3.3"
2322
pytest = "^8.1.1"
2423
pytest-cov = "^4.1.0"
24+
pytest-timeout = "^2.3.1"
2525

2626
[build-system]
2727
requires = ["poetry-core"]
@@ -63,7 +63,7 @@ exclude = ['typings']
6363
[tool.pytest.ini_options]
6464
log_cli = 1
6565
log_cli_level = 'ERROR'
66-
timeout = 4
66+
timeout = 1
6767

6868
[tool.coverage.report]
6969
exclude_also = ["if TYPE_CHECKING:"]

redux/autorun.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33

44
import inspect
55
import weakref
6-
from asyncio import iscoroutinefunction
6+
from asyncio import Task, iscoroutine
77
from inspect import signature
8-
from typing import TYPE_CHECKING, Any, Callable, Coroutine, Generic, cast
8+
from typing import TYPE_CHECKING, Any, Callable, Generic, cast
99

1010
from redux.basic_types import (
1111
Action,
@@ -121,6 +121,20 @@ def call_func(
121121
func,
122122
)(selector_result, previous_result)
123123

124+
def _task_callback(
125+
self: Autorun[
126+
State,
127+
Action,
128+
Event,
129+
SelectorOutput,
130+
ComparatorOutput,
131+
AutorunOriginalReturnType,
132+
],
133+
task: Task,
134+
) -> None:
135+
task.add_done_callback(lambda _: self.inform_subscribers())
136+
self._latest_value = cast(AutorunOriginalReturnType, task)
137+
124138
def _check_and_call(
125139
self: Autorun[
126140
State,
@@ -154,12 +168,11 @@ def _check_and_call(
154168
previous_result,
155169
func,
156170
)
157-
if iscoroutinefunction(func):
158-
task = self._store._async_loop.create_task( # noqa: SLF001
159-
cast(Coroutine, self._latest_value),
171+
if iscoroutine(self._latest_value):
172+
self._store._create_task( # noqa: SLF001
173+
self._latest_value,
174+
callback=self._task_callback,
160175
)
161-
task.add_done_callback(lambda _: self.inform_subscribers())
162-
self._latest_value = cast(AutorunOriginalReturnType, task)
163176
else:
164177
self.inform_subscribers()
165178

@@ -234,12 +247,6 @@ def subscribe(
234247
callback(self.value)
235248

236249
def unsubscribe() -> None:
237-
callback = (
238-
callback_ref()
239-
if isinstance(callback_ref, weakref.ref)
240-
else callback_ref
241-
)
242-
if callback is not None:
243-
self._subscriptions.discard(callback)
250+
self._subscriptions.discard(callback_ref)
244251

245252
return unsubscribe

redux/basic_types.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,22 @@
22
from __future__ import annotations
33

44
from types import NoneType
5-
from typing import TYPE_CHECKING, Any, Callable, Generic, Protocol, TypeAlias, TypeGuard
5+
from typing import (
6+
TYPE_CHECKING,
7+
Any,
8+
Callable,
9+
Coroutine,
10+
Generic,
11+
Protocol,
12+
TypeAlias,
13+
TypeGuard,
14+
)
615

716
from immutable import Immutable
817
from typing_extensions import TypeVar
918

1019
if TYPE_CHECKING:
11-
import asyncio
20+
from asyncio import Task
1221

1322

1423
class BaseAction(Immutable): ...
@@ -77,13 +86,26 @@ class Scheduler(Protocol):
7786
def __call__(self: Scheduler, callback: Callable, *, interval: bool) -> None: ...
7887

7988

89+
class TaskCreatorCallback(Protocol):
90+
def __call__(self: TaskCreatorCallback, task: Task) -> None: ...
91+
92+
93+
class TaskCreator(Protocol):
94+
def __call__(
95+
self: TaskCreator,
96+
coro: Coroutine,
97+
*,
98+
callback: TaskCreatorCallback | None = None,
99+
) -> None: ...
100+
101+
80102
class CreateStoreOptions(Immutable):
81103
auto_init: bool = False
82104
threads: int = 5
83105
scheduler: Scheduler | None = None
84106
action_middleware: Callable[[BaseAction], Any] | None = None
85107
event_middleware: Callable[[BaseEvent], Any] | None = None
86-
async_loop: asyncio.AbstractEventLoop | None = None
108+
task_creator: TaskCreator | None = None
87109

88110

89111
class AutorunOptions(Immutable, Generic[AutorunOriginalReturnType]):

redux/main.py

Lines changed: 42 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,15 @@
22

33
from __future__ import annotations
44

5-
import dataclasses
65
import inspect
76
import queue
8-
import threading
97
import weakref
10-
from asyncio import AbstractEventLoop, get_event_loop, iscoroutinefunction
8+
from asyncio import get_event_loop, iscoroutine
119
from collections import defaultdict
1210
from inspect import signature
1311
from threading import Lock
14-
from types import NoneType
1512
from typing import Any, Callable, Coroutine, Generic, cast
1613

17-
from immutable import Immutable, is_immutable
18-
1914
from redux.autorun import Autorun
2015
from redux.basic_types import (
2116
Action,
@@ -39,45 +34,25 @@
3934
SelectorOutput,
4035
SnapshotAtom,
4136
State,
37+
TaskCreator,
38+
TaskCreatorCallback,
4239
is_complete_reducer_result,
4340
is_state_reducer_result,
4441
)
42+
from redux.serialization_mixin import SerializationMixin
43+
from redux.side_effect_runner import SideEffectRunnerThread
4544

4645

47-
class _SideEffectRunnerThread(threading.Thread, Generic[Event]):
48-
def __init__(
49-
self: _SideEffectRunnerThread[Event],
50-
*,
51-
task_queue: queue.Queue[tuple[EventHandler[Event], Event] | None],
52-
async_loop: AbstractEventLoop,
53-
) -> None:
54-
super().__init__()
55-
self.task_queue = task_queue
56-
self.async_loop = async_loop
57-
58-
def create_task(self: _SideEffectRunnerThread[Event], coro: Coroutine) -> None:
59-
self.async_loop.call_soon_threadsafe(lambda: self.async_loop.create_task(coro))
60-
61-
def run(self: _SideEffectRunnerThread[Event]) -> None:
62-
while True:
63-
task = self.task_queue.get()
64-
if task is None:
65-
self.task_queue.task_done()
66-
break
67-
68-
try:
69-
event_handler, event = task
70-
if len(signature(event_handler).parameters) == 1:
71-
result = cast(Callable[[Event], Any], event_handler)(event)
72-
else:
73-
result = cast(Callable[[], Any], event_handler)()
74-
if iscoroutinefunction(event_handler):
75-
self.create_task(result)
76-
finally:
77-
self.task_queue.task_done()
46+
def _default_task_creator(
47+
coro: Coroutine,
48+
callback: TaskCreatorCallback | None = None,
49+
) -> None:
50+
result = get_event_loop().create_task(coro)
51+
if callback:
52+
callback(result)
7853

7954

80-
class Store(Generic[State, Action, Event]):
55+
class Store(Generic[State, Action, Event], SerializationMixin):
8156
"""Redux store for managing state and side effects."""
8257

8358
def __init__(
@@ -88,7 +63,9 @@ def __init__(
8863
"""Create a new store."""
8964
self.store_options = options or CreateStoreOptions()
9065
self.reducer = reducer
91-
self._async_loop = self.store_options.async_loop or get_event_loop()
66+
self._create_task: TaskCreator = (
67+
self.store_options.task_creator or _default_task_creator
68+
)
9269

9370
self._state: State | None = None
9471
self._listeners: set[
@@ -110,14 +87,14 @@ def __init__(
11087
self._event_handlers_queue = queue.Queue[
11188
tuple[EventHandler[Event], Event] | None
11289
]()
113-
workers = [
114-
_SideEffectRunnerThread(
90+
self._workers = [
91+
SideEffectRunnerThread(
11592
task_queue=self._event_handlers_queue,
116-
async_loop=self._async_loop,
93+
task_creator=self._create_task,
11794
)
11895
for _ in range(self.store_options.threads)
11996
]
120-
for worker in workers:
97+
for worker in self._workers:
12198
worker.start()
12299

123100
self._is_running = Lock()
@@ -158,8 +135,8 @@ def _run_actions(self: Store[State, Action, Event]) -> None:
158135
else:
159136
listener = listener_
160137
result = listener(self._state)
161-
if iscoroutinefunction(listener):
162-
self._async_loop.create_task(result)
138+
if iscoroutine(result):
139+
self._create_task(result)
163140

164141
def _run_event_handlers(self: Store[State, Action, Event]) -> None:
165142
event = self._events.pop(0)
@@ -175,10 +152,13 @@ def _run_event_handlers(self: Store[State, Action, Event]) -> None:
175152
event_handler = event_handler_
176153
if not options.immediate_run:
177154
self._event_handlers_queue.put((event_handler, event))
178-
elif len(signature(event_handler).parameters) == 1:
179-
cast(Callable[[Event], Any], event_handler)(event)
180155
else:
181-
cast(Callable[[], Any], event_handler)()
156+
if len(signature(event_handler).parameters) == 1:
157+
result = cast(Callable[[Event], Any], event_handler)(event)
158+
else:
159+
result = cast(Callable[[], Any], event_handler)()
160+
if iscoroutine(result):
161+
self._create_task(result)
182162

183163
def run(self: Store[State, Action, Event]) -> None:
184164
"""Run the store."""
@@ -189,6 +169,12 @@ def run(self: Store[State, Action, Event]) -> None:
189169

190170
if len(self._events) > 0:
191171
self._run_event_handlers()
172+
if not any(i.is_alive() for i in self._workers):
173+
for worker in self._workers:
174+
worker.join()
175+
self._workers.clear()
176+
self._listeners.clear()
177+
self._event_handlers.clear()
192178

193179
def dispatch(
194180
self: Store[State, Action, Event],
@@ -258,15 +244,15 @@ def subscribe_event(
258244
self._event_handlers[cast(type[Event], event_type)].add(
259245
(handler_ref, subscription_options),
260246
)
261-
return lambda: self._event_handlers[cast(type[Event], event_type)].discard(
262-
(handler_ref, subscription_options),
263-
)
264247

265-
def _handle_finish_event(
266-
self: Store[State, Action, Event],
267-
finish_event: Event,
268-
) -> None:
269-
_ = finish_event
248+
def unsubscribe() -> None:
249+
self._event_handlers[cast(type[Event], event_type)].discard(
250+
(handler_ref, subscription_options),
251+
)
252+
253+
return unsubscribe
254+
255+
def _handle_finish_event(self: Store[State, Action, Event]) -> None:
270256
for _ in range(self.store_options.threads):
271257
self._event_handlers_queue.put(None)
272258

@@ -301,28 +287,3 @@ def decorator(
301287
def snapshot(self: Store[State, Action, Event]) -> SnapshotAtom:
302288
"""Return a snapshot of the current state of the store."""
303289
return self.serialize_value(self._state)
304-
305-
@classmethod
306-
def serialize_value(cls: type[Store], obj: object | type) -> SnapshotAtom:
307-
"""Serialize a value to a snapshot atom."""
308-
if isinstance(obj, (int, float, str, bool, NoneType)):
309-
return obj
310-
if callable(obj):
311-
return cls.serialize_value(obj())
312-
if isinstance(obj, (list, tuple)):
313-
return [cls.serialize_value(i) for i in obj]
314-
if is_immutable(obj):
315-
return cls._serialize_dataclass_to_dict(obj)
316-
msg = f'Unable to serialize object with type `{type(obj)}`.'
317-
raise TypeError(msg)
318-
319-
@classmethod
320-
def _serialize_dataclass_to_dict(
321-
cls: type[Store],
322-
obj: Immutable,
323-
) -> dict[str, Any]:
324-
result = {}
325-
for field in dataclasses.fields(obj):
326-
value = cls.serialize_value(getattr(obj, field.name))
327-
result[field.name] = value
328-
return result

0 commit comments

Comments
 (0)