From 32f2c73ea771f7915b4248c9a2619ab04ca7c40b Mon Sep 17 00:00:00 2001 From: Sassan Haradji Date: Thu, 4 Apr 2024 18:16:26 +0400 Subject: [PATCH] feat: introduce `grace_time_in_seconds` parameter to `Store` to allow a grace period for the store to finish its work before calling `cleanup` and `on_finish` --- .github/workflows/integration_delivery.yml | 73 +++++++++++++--------- CHANGELOG.md | 5 ++ poetry.lock | 44 ++++++------- pyproject.toml | 6 +- redux/basic_types.py | 5 +- redux/main.py | 42 ++++++++----- redux_pytest/fixtures/snapshot.py | 2 +- tests/test_scheduler.py | 30 +++++++-- 8 files changed, 129 insertions(+), 78 deletions(-) diff --git a/.github/workflows/integration_delivery.yml b/.github/workflows/integration_delivery.yml index 26fe5fa..c877648 100644 --- a/.github/workflows/integration_delivery.yml +++ b/.github/workflows/integration_delivery.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v4 name: Checkout - - name: Save Cached Poetry + - name: Load Cached Poetry id: cached-poetry uses: actions/cache@v4 with: @@ -112,36 +112,23 @@ jobs: architecture: x64 - name: Load Cached Poetry - id: cached-poetry uses: actions/cache/restore@v4 + id: cached-poetry with: path: | ~/.cache ~/.local key: poetry-${{ hashFiles('poetry.lock') }} - - name: Test + - name: Run Tests run: poetry run poe test - - name: Prepare list of JSON files with mismatching pairs - if: failure() - run: | - mkdir -p artifacts - for file in $(find tests/ -name "*.mismatch.json"); do - base=${file%.mismatch.json}.json - if [[ -f "$base" ]]; then - echo "$file" >> artifacts/files_to_upload.txt - echo "$base" >> artifacts/files_to_upload.txt - fi - done - - - name: Collect Mismatching Store Snapshots - if: failure() + - name: Collect Store Snapshots uses: actions/upload-artifact@v4 + if: always() with: - name: mismatching-snapshots - path: | - @artifacts/files_to_upload.txt + name: snapshots + path: tests/**/results/**/*.jsonc - name: Collect HTML Coverage Report uses: actions/upload-artifact@v4 @@ -163,8 +150,8 @@ jobs: - dependencies runs-on: ubuntu-latest outputs: - version: ${{ steps.extract_version.outputs.version }} - name: ${{ steps.extract_version.outputs.name }} + version: ${{ steps.extract_version.outputs.VERSION }} + name: ${{ steps.extract_version.outputs.NAME }} steps: - uses: actions/checkout@v4 name: Checkout @@ -184,14 +171,41 @@ jobs: ~/.local key: poetry-${{ hashFiles('poetry.lock') }} - - name: Build - run: poetry build - - name: Extract Version id: extract_version run: | - echo "version=$(poetry version --short)" >> "$GITHUB_OUTPUT" - echo "name=$(poetry version | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + echo "VERSION=$(poetry version --short)" >> "$GITHUB_OUTPUT" + echo "NAME=$(poetry version | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + echo "VERSION=$(poetry version --short)" + echo "NAME=$(poetry version | cut -d' ' -f1)" + + - name: Extract Version from CHANGELOG.md + id: extract_changelog_version + run: | + VERSION_CHANGELOG=$(sed -n '3 s/## Version //p' CHANGELOG.md) + echo "VERSION_CHANGELOG=$VERSION_CHANGELOG" + if [ "${{ steps.extract_version.outputs.VERSION }}" != "$VERSION_CHANGELOG" ]; then + echo "Error: Version extracted from CHANGELOG.md does not match the version in pyproject.toml" + exit 1 + else + echo "Versions are consistent." + fi + + - name: Extract Version from Tag + if: startsWith(github.ref, 'refs/tags/v') + id: extract_tag_version + run: | + VERSION_TAG=$(sed 's/^v//' <<< ${{ github.ref_name }}) + echo "VERSION_TAG=$VERSION_TAG" + if [ "${{ steps.extract_version.outputs.VERSION }}" != "$VERSION_TAG" ]; then + echo "Error: Version extracted from tag does not match the version in pyproject.toml" + exit 1 + else + echo "Versions are consistent." + fi + + - name: Build + run: poetry build - name: Upload wheel uses: actions/upload-artifact@v4 @@ -240,6 +254,7 @@ jobs: verbose: true release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') name: Release needs: - type-check @@ -247,12 +262,12 @@ jobs: - test - build - pypi-publish + runs-on: ubuntu-latest environment: name: release - runs-on: ubuntu-latest + url: https://pypi.org/p/${{ needs.build.outputs.name }} permissions: contents: write - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') steps: - name: Procure Wheel uses: actions/download-artifact@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf2973..43d8ad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Version 0.14.1 + +- feat: introduce `grace_time_in_seconds` parameter to `Store` to allow a grace + period for the store to finish its work before calling `cleanup` and `on_finish` + ## Version 0.14.0 - refactor: `Store` no longer aggregates changes, it now calls listeners with every diff --git a/poetry.lock b/poetry.lock index 7f1d087..25ecb0d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -157,13 +157,13 @@ poetry-plugin = ["poetry (>=1.0,<2.0)"] [[package]] name = "pyright" -version = "1.1.356" +version = "1.1.357" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.356-py3-none-any.whl", hash = "sha256:a101b0f375f93d7082f9046cfaa7ba15b7cf8e1939ace45e984c351f6e8feb99"}, - {file = "pyright-1.1.356.tar.gz", hash = "sha256:f05b8b29d06b96ed4a0885dad5a31d9dff691ca12b2f658249f583d5f2754021"}, + {file = "pyright-1.1.357-py3-none-any.whl", hash = "sha256:1cf29ee38e4928131895cd8e90eef37b5b77e2ed72a14e6e8e2405266f5f0aca"}, + {file = "pyright-1.1.357.tar.gz", hash = "sha256:7c66261116c78c5fa9629134fe85c54cc5302ab73e376be4b0a99d89c80a9403"}, ] [package.dependencies] @@ -258,28 +258,28 @@ typing-extensions = ">=4.10.0,<5.0.0" [[package]] name = "ruff" -version = "0.3.4" +version = "0.3.5" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.3.4-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:60c870a7d46efcbc8385d27ec07fe534ac32f3b251e4fc44b3cbfd9e09609ef4"}, - {file = "ruff-0.3.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6fc14fa742e1d8f24910e1fff0bd5e26d395b0e0e04cc1b15c7c5e5fe5b4af91"}, - {file = "ruff-0.3.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3ee7880f653cc03749a3bfea720cf2a192e4f884925b0cf7eecce82f0ce5854"}, - {file = "ruff-0.3.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf133dd744f2470b347f602452a88e70dadfbe0fcfb5fd46e093d55da65f82f7"}, - {file = "ruff-0.3.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f3860057590e810c7ffea75669bdc6927bfd91e29b4baa9258fd48b540a4365"}, - {file = "ruff-0.3.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:986f2377f7cf12efac1f515fc1a5b753c000ed1e0a6de96747cdf2da20a1b369"}, - {file = "ruff-0.3.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fd98e85869603e65f554fdc5cddf0712e352fe6e61d29d5a6fe087ec82b76c"}, - {file = "ruff-0.3.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64abeed785dad51801b423fa51840b1764b35d6c461ea8caef9cf9e5e5ab34d9"}, - {file = "ruff-0.3.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df52972138318bc7546d92348a1ee58449bc3f9eaf0db278906eb511889c4b50"}, - {file = "ruff-0.3.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:98e98300056445ba2cc27d0b325fd044dc17fcc38e4e4d2c7711585bd0a958ed"}, - {file = "ruff-0.3.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:519cf6a0ebed244dce1dc8aecd3dc99add7a2ee15bb68cf19588bb5bf58e0488"}, - {file = "ruff-0.3.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bb0acfb921030d00070539c038cd24bb1df73a2981e9f55942514af8b17be94e"}, - {file = "ruff-0.3.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cf187a7e7098233d0d0c71175375c5162f880126c4c716fa28a8ac418dcf3378"}, - {file = "ruff-0.3.4-py3-none-win32.whl", hash = "sha256:af27ac187c0a331e8ef91d84bf1c3c6a5dea97e912a7560ac0cef25c526a4102"}, - {file = "ruff-0.3.4-py3-none-win_amd64.whl", hash = "sha256:de0d5069b165e5a32b3c6ffbb81c350b1e3d3483347196ffdf86dc0ef9e37dd6"}, - {file = "ruff-0.3.4-py3-none-win_arm64.whl", hash = "sha256:6810563cc08ad0096b57c717bd78aeac888a1bfd38654d9113cb3dc4d3f74232"}, - {file = "ruff-0.3.4.tar.gz", hash = "sha256:f0f4484c6541a99862b693e13a151435a279b271cff20e37101116a21e2a1ad1"}, + {file = "ruff-0.3.5-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:aef5bd3b89e657007e1be6b16553c8813b221ff6d92c7526b7e0227450981eac"}, + {file = "ruff-0.3.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:89b1e92b3bd9fca249153a97d23f29bed3992cff414b222fcd361d763fc53f12"}, + {file = "ruff-0.3.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e55771559c89272c3ebab23326dc23e7f813e492052391fe7950c1a5a139d89"}, + {file = "ruff-0.3.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dabc62195bf54b8a7876add6e789caae0268f34582333cda340497c886111c39"}, + {file = "ruff-0.3.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a05f3793ba25f194f395578579c546ca5d83e0195f992edc32e5907d142bfa3"}, + {file = "ruff-0.3.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:dfd3504e881082959b4160ab02f7a205f0fadc0a9619cc481982b6837b2fd4c0"}, + {file = "ruff-0.3.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87258e0d4b04046cf1d6cc1c56fadbf7a880cc3de1f7294938e923234cf9e498"}, + {file = "ruff-0.3.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:712e71283fc7d9f95047ed5f793bc019b0b0a29849b14664a60fd66c23b96da1"}, + {file = "ruff-0.3.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a532a90b4a18d3f722c124c513ffb5e5eaff0cc4f6d3aa4bda38e691b8600c9f"}, + {file = "ruff-0.3.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:122de171a147c76ada00f76df533b54676f6e321e61bd8656ae54be326c10296"}, + {file = "ruff-0.3.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d80a6b18a6c3b6ed25b71b05eba183f37d9bc8b16ace9e3d700997f00b74660b"}, + {file = "ruff-0.3.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a7b6e63194c68bca8e71f81de30cfa6f58ff70393cf45aab4c20f158227d5936"}, + {file = "ruff-0.3.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a759d33a20c72f2dfa54dae6e85e1225b8e302e8ac655773aff22e542a300985"}, + {file = "ruff-0.3.5-py3-none-win32.whl", hash = "sha256:9d8605aa990045517c911726d21293ef4baa64f87265896e491a05461cae078d"}, + {file = "ruff-0.3.5-py3-none-win_amd64.whl", hash = "sha256:dc56bb16a63c1303bd47563c60482a1512721053d93231cf7e9e1c6954395a0e"}, + {file = "ruff-0.3.5-py3-none-win_arm64.whl", hash = "sha256:faeeae9905446b975dcf6d4499dc93439b131f1443ee264055c5716dd947af55"}, + {file = "ruff-0.3.5.tar.gz", hash = "sha256:a067daaeb1dc2baf9b82a32dae67d154d95212080c80435eb052d95da647763d"}, ] [[package]] @@ -337,4 +337,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "d43fa819bda251288e990597e60360b8c4a9d9f94077bbab05ee4a4944410a85" +content-hash = "31b4818ff5368cfa488f7abffd4145458ef65de25cc7f3cd41141e42f161df24" diff --git a/pyproject.toml b/pyproject.toml index ecbd21b..8060aeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-redux" -version = "0.14.0" +version = "0.14.1" description = "Redux implementation for Python" authors = ["Sassan Haradji "] license = "Apache-2.0" @@ -16,8 +16,8 @@ optional = true [tool.poetry.group.dev.dependencies] poethepoet = "^0.24.4" -pyright = "^1.1.354" -ruff = "^0.3.3" +pyright = "^1.1.357" +ruff = "^0.3.5" pytest = "^8.1.1" pytest-cov = "^4.1.0" pytest-timeout = "^2.3.1" diff --git a/redux/basic_types.py b/redux/basic_types.py index 9682475..68e4e56 100644 --- a/redux/basic_types.py +++ b/redux/basic_types.py @@ -42,8 +42,8 @@ class BaseEvent(Immutable): ... class CompleteReducerResult(Immutable, Generic[State, Action, Event]): state: State - actions: list[Action] | None = None - events: list[Event] | None = None + actions: Sequence[Action] | None = None + events: Sequence[Event] | None = None ReducerResult = CompleteReducerResult[State, Action, Event] | State @@ -112,6 +112,7 @@ class CreateStoreOptions(Immutable, Generic[Action, Event]): event_middlewares: Sequence[EventMiddleware[Event]] = field(default_factory=list) task_creator: TaskCreator | None = None on_finish: Callable[[], Any] | None = None + grace_time_in_seconds: float = 1 class AutorunOptions(Immutable, Generic[AutorunOriginalReturnType]): diff --git a/redux/main.py b/redux/main.py index f3b3c4f..86296e0 100644 --- a/redux/main.py +++ b/redux/main.py @@ -7,7 +7,7 @@ import queue import weakref from collections import defaultdict -from threading import Lock +from threading import Lock, Thread from typing import Any, Callable, Generic, cast from redux.autorun import Autorun @@ -50,7 +50,6 @@ def __init__( options: CreateStoreOptions[Action, Event] | None = None, ) -> None: """Create a new store.""" - self.finished = False self.store_options = options or CreateStoreOptions() self.reducer = reducer self._create_task = self.store_options.task_creator @@ -125,8 +124,8 @@ def _run_actions(self: Store[State, Action, Event]) -> None: def _run_event_handlers(self: Store[State, Action, Event]) -> None: event = self._events.pop(0) - for event_handler_ in self._event_handlers[type(event)].copy(): - self._event_handlers_queue.put_nowait((event_handler_, event)) + for event_handler in self._event_handlers[type(event)].copy(): + self._event_handlers_queue.put_nowait((event_handler, event)) def run(self: Store[State, Action, Event]) -> None: """Run the store.""" @@ -137,13 +136,6 @@ def run(self: Store[State, Action, Event]) -> None: if len(self._events) > 0: self._run_event_handlers() - if ( - self.finished - and self._actions == [] - and self._events == [] - and not any(worker.is_alive() for worker in self._workers) - ): - self.clean_up() def clean_up(self: Store[State, Action, Event]) -> None: """Clean up the store.""" @@ -152,8 +144,6 @@ def clean_up(self: Store[State, Action, Event]) -> None: self._workers.clear() self._listeners.clear() self._event_handlers.clear() - if self.store_options.on_finish: - self.store_options.on_finish() def dispatch( self: Store[State, Action, Event], @@ -225,10 +215,30 @@ def unsubscribe() -> None: return unsubscribe + def wait_for_store_to_finish(self: Store[State, Action, Event]) -> None: + """Wait for the store to finish.""" + import time + + while True: + if ( + self._actions == [] + and self._events == [] + and self._event_handlers_queue.qsize() == 0 + ): + time.sleep(self.store_options.grace_time_in_seconds) + self._event_handlers_queue.join() + for _ in range(self.store_options.threads): + self._event_handlers_queue.put_nowait(None) + self._event_handlers_queue.join() + self.clean_up() + if self.store_options.on_finish: + self.store_options.on_finish() + break + time.sleep(0.1) + def _handle_finish_event(self: Store[State, Action, Event]) -> None: - for _ in range(self.store_options.threads): - self._event_handlers_queue.put_nowait(None) - self.finished = True + thread = Thread(target=self.wait_for_store_to_finish) + thread.start() def autorun( self: Store[State, Action, Event], diff --git a/redux_pytest/fixtures/snapshot.py b/redux_pytest/fixtures/snapshot.py index e3388eb..cb77c06 100644 --- a/redux_pytest/fixtures/snapshot.py +++ b/redux_pytest/fixtures/snapshot.py @@ -39,7 +39,7 @@ def __init__( self.results_dir = path.parent / 'results' / file / test_id.split('::')[-1][5:] if self.results_dir.exists(): for file in self.results_dir.glob( - 'store-*' if override else 'store-*.mismatch.json', + 'store-*.jsonc' if override else 'store-*.mismatch.jsonc', ): file.unlink() # pragma: no cover self.results_dir.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 76c8243..6318d08 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -1,4 +1,4 @@ -# ruff: noqa: D100, D101, D102, D103, D104, D107, T201 +# ruff: noqa: D100, D101, D102, D103, D104, D107 from __future__ import annotations import asyncio @@ -11,11 +11,14 @@ from redux.basic_types import ( BaseAction, + BaseEvent, + CompleteReducerResult, CreateStoreOptions, FinishAction, FinishEvent, InitAction, InitializationActionError, + ReducerResult, ) from redux.main import Store @@ -30,14 +33,27 @@ class StateType(Immutable): class IncrementAction(BaseAction): ... -def reducer(state: StateType | None, action: IncrementAction | InitAction) -> StateType: +class WaitEvent(BaseEvent): ... + + +Action = IncrementAction | InitAction | FinishAction +Event = WaitEvent + + +def reducer( + state: StateType | None, + action: Action, +) -> ReducerResult[StateType, Action, Event]: if state is None: if isinstance(action, InitAction): return StateType(value=0) raise InitializationActionError(action) if isinstance(action, IncrementAction): - return replace(state, value=state.value + 1) + return CompleteReducerResult( + state=replace(state, value=state.value + 1), + events=[WaitEvent()], + ) return state @@ -108,11 +124,17 @@ def create_task_with_callback() -> None: scheduler=scheduler.set, task_creator=_create_task_with_callback, on_finish=scheduler.schedule_stop, + grace_time_in_seconds=0.2, ), ) render = mocker.stub() + store.subscribe_event( + FinishEvent, + lambda _: time.sleep(0.1) or store.dispatch(IncrementAction()), + ) + store.subscribe(render) import time @@ -127,5 +149,3 @@ def create_task_with_callback() -> None: render.assert_has_calls( [call(StateType(value=i)) for i in range(11)] + [call(StateType(value=10))], ) - - print(3)