diff --git a/README.md b/README.md index 947f6d2..90a554e 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ Inter-task sychronization and communication. Pin the minor version. ``` -poetry add asyncgui-ext-synctools@~0.1 -pip install "asyncgui-ext-synctools>=0.1,<0.2" +poetry add asyncgui-ext-synctools@~0.2 +pip install "asyncgui-ext-synctools>=0.2,<0.3" ``` ## Tested on diff --git a/poetry.lock b/poetry.lock index a33b37c..c68b20e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "alabaster" @@ -11,6 +11,20 @@ files = [ {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, ] +[[package]] +name = "asyncgui" +version = "0.6.3" +description = "A thin layer that helps to wrap a callback-style API in an async/await-style API" +optional = false +python-versions = "<4.0.0,>=3.8.1" +files = [ + {file = "asyncgui-0.6.3-py3-none-any.whl", hash = "sha256:46723e65d8bf023e28d141f6a9d38634ef5b97a5236fafdaf80b47e13275a5eb"}, + {file = "asyncgui-0.6.3.tar.gz", hash = "sha256:05de49c2221128d3530f010df98491f3553183ec8909d205a0c0250bee5d6e0c"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.4,<2.0.0", markers = "python_version < \"3.11\""} + [[package]] name = "babel" version = "2.15.0" @@ -48,13 +62,13 @@ lxml = ["lxml"] [[package]] name = "certifi" -version = "2024.6.2" +version = "2024.7.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.6.2-py3-none-any.whl", hash = "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56"}, - {file = "certifi-2024.6.2.tar.gz", hash = "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516"}, + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, ] [[package]] @@ -261,18 +275,17 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "livereload" -version = "2.6.3" +version = "2.7.0" description = "Python LiveReload is an awesome tool for web developers" optional = false -python-versions = "*" +python-versions = ">=3.7" files = [ - {file = "livereload-2.6.3-py2.py3-none-any.whl", hash = "sha256:ad4ac6f53b2d62bb6ce1a5e6e96f1f00976a32348afedcb4b6d68df2a1d346e4"}, - {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"}, + {file = "livereload-2.7.0-py3-none-any.whl", hash = "sha256:19bee55aff51d5ade6ede0dc709189a0f904d3b906d3ea71641ed548acff3246"}, + {file = "livereload-2.7.0.tar.gz", hash = "sha256:f4ba199ef93248902841e298670eebfe1aa9e148e19b343bc57dbf1b74de0513"}, ] [package.dependencies] -six = "*" -tornado = {version = "*", markers = "python_version > \"2.7\""} +tornado = "*" [[package]] name = "markupsafe" @@ -426,17 +439,6 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - [[package]] name = "snowballstemmer" version = "2.2.0" @@ -675,4 +677,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "9ba7aa443f451f2275a1b7c975616f96e1e1e44d9946174bfefc651dd60ff984" +content-hash = "7413bee58275c87c12ec7887c5a5130c9ccb8db0a4f895a2e0b2580414495779" diff --git a/pyproject.toml b/pyproject.toml index 8a68099..1df108a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "asyncgui-ext-synctools" -version = "0.1.0" +version = "0.2.0" description = "Inter-task sychronization and communication." authors = ["Nattōsai Mitō "] license = "MIT" diff --git a/src/asyncgui_ext/synctools/all.py b/src/asyncgui_ext/synctools/all.py index 6eb7657..f964b1c 100644 --- a/src/asyncgui_ext/synctools/all.py +++ b/src/asyncgui_ext/synctools/all.py @@ -1,4 +1,6 @@ __all__ = ( - 'Event', + 'Event', 'Box', ) from .event import Event +from .box import Box + diff --git a/src/asyncgui_ext/synctools/box.py b/src/asyncgui_ext/synctools/box.py new file mode 100644 index 0000000..a010dae --- /dev/null +++ b/src/asyncgui_ext/synctools/box.py @@ -0,0 +1,74 @@ +__all__ = ('Box', ) +import types + + +class Box: + ''' + Similar to :class:`asyncgui.AsyncBox`, but this one can handle multiple tasks simultaneously. + This is the closest thing to :class:`asyncio.Event` in this library. + + .. code-block:: + + async def async_fn(b1, b2): + args, kwargs = await b1.get() + assert args == (1, ) + assert kwargs == {'crow': 'raven', } + + args, kwargs = await b2.get() + assert args == (2, ) + assert kwargs == {'frog': 'toad', } + + args, kwargs = await b1.get() + assert args == (1, ) + assert kwargs == {'crow': 'raven', } + + b1 = Box() + b2 = Box() + b1.put(1, crow='raven') + start(async_fn(b1, b2)) + b2.put(2, frog='toad') + ''' + + __slots__ = ('_item', '_waiting_tasks', ) + + def __init__(self): + self._item = None + self._waiting_tasks = [] + + @property + def is_empty(self) -> bool: + return self._item is None + + def put(self, *args, **kwargs): + '''Put an item into the box if it's empty.''' + if self._item is None: + self.put_or_update(*args, **kwargs) + + def update(self, *args, **kwargs): + '''Replace the item in the box if there is one already.''' + if self._item is not None: + self.put_or_update(*args, **kwargs) + + def put_or_update(self, *args, **kwargs): + self._item = (args, kwargs, ) + tasks = self._waiting_tasks + self._waiting_tasks = [] + for t in tasks: + if t is not None: + t._step(*args, **kwargs) + + def clear(self): + '''Remove the item from the box if there is one.''' + self._item = None + + @types.coroutine + def get(self): + '''Get the item from the box if there is one. Otherwise, wait until it's put.''' + if self._item is not None: + return self._item + tasks = self._waiting_tasks + idx = len(tasks) + try: + return (yield tasks.append) + finally: + tasks[idx] = None diff --git a/src/asyncgui_ext/synctools/event.py b/src/asyncgui_ext/synctools/event.py index 20f2694..f56f07a 100644 --- a/src/asyncgui_ext/synctools/event.py +++ b/src/asyncgui_ext/synctools/event.py @@ -1,64 +1,46 @@ -__all__ = ( - 'Event', -) +__all__ = ('Event', ) import types -import typing as T class Event: ''' - Similar to :class:`asyncio.Event`. - The differences are: - - * :meth:`set` accepts any number of arguments but doesn't use them at all so it can be used as a callback function - in any library. - * :attr:`is_set` is a property not a function. + Similar to :class:`asyncgui.AsyncEvent`, but this one can handle multiple tasks simultaneously. .. code-block:: + async def async_fn(e): + args, kwargs = await e.wait() + assert args == (2, ) + assert kwargs == {'crow': 'raven', } + + args, kwargs = await e.wait() + assert args == (3, ) + assert kwargs == {'toad': 'frog', } + e = Event() - any_library.register_callback(e.set) + e.fire(1, crocodile='alligator') + start(async_fn(e)) + e.fire(2, crow='raven') + e.fire(3, toad='frog') ''' - __slots__ = ('_flag', '_waiting_tasks', ) + __slots__ = ('_waiting_tasks', ) def __init__(self): - self._flag = False self._waiting_tasks = [] - @property - def is_set(self) -> bool: - return self._flag - - def set(self, *args, **kwargs): - ''' - Set the event. - Unlike asyncio's, all tasks waiting for this event to be set will be resumed *immediately*. - ''' - if self._flag: - return - self._flag = True + def fire(self, *args, **kwargs): tasks = self._waiting_tasks self._waiting_tasks = [] for t in tasks: if t is not None: - t._step() - - def clear(self): - '''Unset the event.''' - self._flag = False + t._step(*args, **kwargs) @types.coroutine - def wait(self) -> T.Awaitable: - ''' - Wait for the event to be set. - Return *immediately* if it's already set. - ''' - if self._flag: - return + def wait(self): + tasks = self._waiting_tasks + idx = len(tasks) try: - tasks = self._waiting_tasks - idx = len(tasks) - yield tasks.append + return (yield tasks.append) finally: tasks[idx] = None diff --git a/tests/test_box.py b/tests/test_box.py new file mode 100644 index 0000000..71d46ed --- /dev/null +++ b/tests/test_box.py @@ -0,0 +1,102 @@ +import pytest + + +def test_get_then_put(): + import asyncgui as ag + from asyncgui_ext.synctools.box import Box + TS = ag.TaskState + b = Box() + t1 = ag.start(b.get()) + t2 = ag.start(b.get()) + assert t1.state is TS.STARTED + assert t2.state is TS.STARTED + b.put(7, crow='raven') + assert t1.result == ((7, ), {'crow': 'raven', }) + assert t2.result == ((7, ), {'crow': 'raven', }) + + +def test_put_then_get(): + import asyncgui as ag + from asyncgui_ext.synctools.box import Box + TS = ag.TaskState + b = Box() + b.put(7, crow='raven') + t1 = ag.start(b.get()) + t2 = ag.start(b.get()) + assert t1.state is TS.FINISHED + assert t2.state is TS.FINISHED + assert t1.result == ((7, ), {'crow': 'raven', }) + assert t2.result == ((7, ), {'crow': 'raven', }) + + +def test_clear(): + import asyncgui as ag + from asyncgui_ext.synctools.box import Box + b1 = Box() + b2 = Box() + + async def async_fn(): + assert (await b1.get()) == ((7, ), {'crow': 'raven', }) + assert (await b2.get()) == ((6, ), {'crocodile': 'alligator', }) + assert (await b1.get()) == ((5, ), {'toad': 'frog', }) + + task = ag.start(async_fn()) + b1.put(7, crow='raven') + b1.clear() + b2.put(6, crocodile='alligator') + b1.put(5, toad='frog') + assert task.finished + + +def test_cancel(): + import asyncgui as ag + from asyncgui_ext.synctools.box import Box + TS = ag.TaskState + + async def async_fn(ctx, b): + async with ag.open_cancel_scope() as scope: + ctx['scope'] = scope + await b.get() + pytest.fail() + await ag.sleep_forever() + + ctx = {} + b = Box() + task = ag.start(async_fn(ctx, b)) + assert task.state is TS.STARTED + ctx['scope'].cancel() + assert task.state is TS.STARTED + b.put() + assert task.state is TS.STARTED + task._step() + assert task.state is TS.FINISHED + + +def test_complicated_cancel(): + import asyncgui as ag + from asyncgui_ext.synctools.box import Box + TS = ag.TaskState + + async def async_fn_1(ctx, b): + await b.get() + ctx['scope'].cancel() + + async def async_fn_2(ctx, b): + async with ag.open_cancel_scope() as scope: + ctx['scope'] = scope + await b.get() + pytest.fail() + await ag.sleep_forever() + + ctx = {} + b = Box() + t1 = ag.start(async_fn_1(ctx, b)) + t2 = ag.start(async_fn_2(ctx, b)) + assert b._waiting_tasks == [t1, t2, ] + assert t2.state is TS.STARTED + b.put() + assert t1.state is TS.FINISHED + assert t2.state is TS.STARTED + assert b._waiting_tasks == [] + t2._step() + assert t2.state is TS.FINISHED diff --git a/tests/test_event.py b/tests/test_event.py index 9683206..fa3e7f7 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -1,59 +1,33 @@ import pytest -def test_wait_then_set(): +def test_wait_then_fire(): import asyncgui as ag from asyncgui_ext.synctools.event import Event TS = ag.TaskState e = Event() - task1 = ag.start(e.wait()) - task2 = ag.start(e.wait()) - assert task1.state is TS.STARTED - assert task2.state is TS.STARTED - e.set() - assert task1.state is TS.FINISHED - assert task2.state is TS.FINISHED + t1 = ag.start(e.wait()) + t2 = ag.start(e.wait()) + assert t1.state is TS.STARTED + assert t2.state is TS.STARTED + e.fire(7, crow='raven') + assert t1.result == ((7, ), {'crow': 'raven', }) + assert t2.result == ((7, ), {'crow': 'raven', }) -def test_set_then_wait(): +def test_fire_then_wait_then_fire(): import asyncgui as ag from asyncgui_ext.synctools.event import Event TS = ag.TaskState e = Event() - e.set() - task1 = ag.start(e.wait()) - task2 = ag.start(e.wait()) - assert task1.state is TS.FINISHED - assert task2.state is TS.FINISHED - - -def test_clear(): - import asyncgui as ag - from asyncgui_ext.synctools.event import Event - e1 = Event() - e2 = Event() - - async def main(): - nonlocal task_state - task_state = 'A' - await e1.wait() - task_state = 'B' - await e2.wait() - task_state = 'C' - await e1.wait() - task_state = 'D' - - task_state = None - ag.start(main()) - assert task_state == 'A' - e1.set() - assert task_state == 'B' - e1.clear() - assert task_state == 'B' - e2.set() - assert task_state == 'C' - e1.set() - assert task_state == 'D' + e.fire(8, crocodile='alligator') + t1 = ag.start(e.wait()) + t2 = ag.start(e.wait()) + assert t1.state is TS.STARTED + assert t2.state is TS.STARTED + e.fire(7, crow='raven') + assert t1.result == ((7, ), {'crow': 'raven', }) + assert t2.result == ((7, ), {'crow': 'raven', }) def test_cancel(): @@ -74,7 +48,7 @@ async def async_fn(ctx, e): assert task.state is TS.STARTED ctx['scope'].cancel() assert task.state is TS.STARTED - e.set() + e.fire() assert task.state is TS.STARTED task._step() assert task.state is TS.FINISHED @@ -86,7 +60,7 @@ def test_complicated_cancel(): TS = ag.TaskState async def async_fn_1(ctx, e): - await e.wait() + assert (await e.wait()) == ((7, ), {'crow': 'raven', }) ctx['scope'].cancel() async def async_fn_2(ctx, e): @@ -98,13 +72,13 @@ async def async_fn_2(ctx, e): ctx = {} e = Event() - task1 = ag.start(async_fn_1(ctx, e)) - task2 = ag.start(async_fn_2(ctx, e)) - assert e._waiting_tasks == [task1, task2, ] - assert task2.state is TS.STARTED - e.set() - assert task1.state is TS.FINISHED - assert task2.state is TS.STARTED + t1 = ag.start(async_fn_1(ctx, e)) + t2 = ag.start(async_fn_2(ctx, e)) + assert e._waiting_tasks == [t1, t2, ] + assert t2.state is TS.STARTED + e.fire(7, crow='raven') + assert t1.state is TS.FINISHED + assert t2.state is TS.STARTED assert e._waiting_tasks == [] - task2._step() - assert task2.state is TS.FINISHED + t2._step() + assert t2.result is None