From 6707ae0a992d7f03f33ac28877f17e72065d5515 Mon Sep 17 00:00:00 2001 From: Johannes Roos Date: Mon, 23 Sep 2024 17:13:33 +0200 Subject: [PATCH] new qt api (futures/generators) --- koil/composition/base.py | 15 +- koil/composition/qt.py | 3 - koil/qt.py | 142 ++++++++++++++++-- pyproject.toml | 2 +- tests/test_qtwidget_modern.py | 269 ++++++++++++++++++++++++++++++++++ 5 files changed, 405 insertions(+), 26 deletions(-) create mode 100644 tests/test_qtwidget_modern.py diff --git a/koil/composition/base.py b/koil/composition/base.py index 5a59e89..0143d82 100644 --- a/koil/composition/base.py +++ b/koil/composition/base.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict from koil.decorators import koilable from typing import Optional, TypeVar, Any from koil.koil import KoilMixin @@ -7,13 +7,14 @@ class PedanticKoil(BaseModel, KoilMixin): + model_config: ConfigDict = ConfigDict(arbitrary_types_allowed=True, extra="forbid") creating_instance: Optional[Any] = Field(default=None, exclude=True) running: bool = False name: str = "KoilLoop" uvify: bool = True grace_period: Optional[float] = None - grant_sync = True - sync_in_async = False + grant_sync: bool = True + sync_in_async: bool = False _token = None _loop = None @@ -21,14 +22,12 @@ class PedanticKoil(BaseModel, KoilMixin): def _repr_html_inline_(self): return f"
allow sync in async{self.sync_in_async}
uvified{self.uvify}
" - class Config: - arbitrary_types_allowed = True - underscore_attrs_are_private = True @koilable(fieldname="koil", add_connectors=True, koil_class=PedanticKoil) class KoiledModel(BaseModel): koil: PedanticKoil = Field(default_factory=PedanticKoil, exclude=True) + model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid") def __enter__(self: T) -> T: ... @@ -48,10 +47,6 @@ async def __aenter__(self: T) -> T: async def __aexit__(self, exc_type, exc_val, exc_tb): pass - class Config: - arbitrary_types_allowed = True - extra = "forbid" - copy_on_model_validation = "none" class Composition(KoiledModel): diff --git a/koil/composition/qt.py b/koil/composition/qt.py index b5b76f5..00fad06 100644 --- a/koil/composition/qt.py +++ b/koil/composition/qt.py @@ -13,6 +13,3 @@ class QtPedanticKoil(PedanticKoil, QtKoilMixin): parent: Optional[QtWidgets.QWidget] = None _qobject: Optional[QtCore.QObject] = None - class Config: - underscore_attrs_are_private = True - arbitrary_types_allowed = True diff --git a/koil/qt.py b/koil/qt.py index 600ebda..5f34595 100644 --- a/koil/qt.py +++ b/koil/qt.py @@ -29,22 +29,52 @@ class UnconnectedSignalError(Exception): pass +T = TypeVar("T") -class QtFuture: - def __init__(self): +class QtFuture(QtCore.QObject,Generic[T]): + """ A future that can be resolved in the Qt event loop + + Qt Futures are futures that can be resolved in the Qt event loop. They are + useful for functions that need to be resolved in the Qt event loop and are + not compatible with the asyncio event loop. + + QtFutures are generic and should be passed the type of the return value + when creating the future. This is useful for type hinting and for the + future to know what type of value to expect when it is resolved. + + + + """ + cancelled: QtCore.Signal = QtCore.Signal() + + + + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.id = uuid.uuid4().hex self.loop = asyncio.get_event_loop() self.aiofuture = asyncio.Future() self.iscancelled = False + self.resolved = False + self.rejected = False def _set_cancelled(self): """WIll be called by the asyncio loop""" + self.cancelled.emit() self.iscancelled = True - def resolve(self, *args): + @property + def done(self): + return self.resolved or self.rejected or self.iscancelled + + + + def resolve(self, *args: T): if not args: args = (None,) ctx = contextvars.copy_context() + self.resolved = True if self.aiofuture.done(): logger.warning(f"QtFuture {self} already done. Cannot resolve") @@ -56,6 +86,8 @@ def reject(self, exp: Exception): if self.aiofuture.done(): logger.warning(f"QtFuture {self} already done. Could not reject") return + + self.rejected = True self.loop.call_soon_threadsafe(self.aiofuture.set_exception, exp) @@ -64,8 +96,26 @@ class KoilStopIteration(Exception): pass -class QtGenerator: + +T = TypeVar("T") + +class QtGenerator(QtCore.QObject, Generic[T]): + """A generator that can be run in the Qt event loop + + Qt Generators are generators that can be run in the Qt event loop. They are + useful for functions that need to be run in the Qt event loop and are not + compatible with the asyncio event loop. + + Qt Generators are generic and should be passed the type of the yield value + when creating the generator. This is useful for type hinting and for the + generator to know what type of value to expect when it is yielded. + + """ + cancelled: QtCore.Signal = QtCore.Signal() + def __init__(self): + super().__init__() + self.id = uuid.uuid4().hex self.loop = asyncio.get_event_loop() self.aioqueue = asyncio.Queue() self.iscancelled = False @@ -74,8 +124,8 @@ def _set_cancelled(self): """WIll be called by the asyncio loop""" self.iscancelled = True - def next(self, *args): - self.loop.call_soon_threadsafe(self.aioqueue.put_nowait, *args) + def next(self, args: T): + self.loop.call_soon_threadsafe(self.aioqueue.put_nowait, args) def throw(self, exception): self.loop.call_soon_threadsafe(self.aioqueue.put_nowait, exception) @@ -83,6 +133,7 @@ def throw(self, exception): def stop(self): self.loop.call_soon_threadsafe(self.aioqueue.put_nowait, KoilStopIteration()) + def __aiter__(self): return self @@ -94,6 +145,7 @@ async def __anext__(self): if isinstance(res, Exception): raise res except asyncio.CancelledError: + self.cancelled.emit() raise StopAsyncIteration return res @@ -160,6 +212,57 @@ async def acall(self, *args: P.args, timeout=None, **kwargs: P.kwargs): raise +class QtYielder(QtCore.QObject, Generic[T, P]): + called = QtCore.Signal(QtGenerator, tuple, dict, object) + cancelled = QtCore.Signal(QtGenerator) + + def __init__( + self, + coro: Callable[P, T], + use_context=True, + **kwargs + ): + super().__init__(**kwargs) + assert not inspect.isgeneratorfunction( + coro + ), f"This should not be a coroutine, but a normal qt slot {'with the first parameter being a qtfuture' if autoresolve is False else ''}" + self.coro = coro + self.called.connect(self.on_called) + self.use_context = use_context + + def on_called(self, generator: QtGenerator, args, kwargs, ctx): + try: + self.coro(generator, *args, **kwargs) + + except Exception as e: + logger.error(f"Error in QtYieldre {self.coro}", exc_info=True) + generator.throw(e) + + async def aiterate(self, *args: P.args, timeout=None, **kwargs: P.kwargs): + generator = QtGenerator() + ctx = contextvars.copy_context() + self.called.emit(generator, args, kwargs, ctx) + try: + while True: + + if timeout: + x = await asyncio.wait_for(anext(generator), timeout=timeout) + else: + x = await anext(generator) + + yield x + + except StopAsyncIteration: + pass + return + + except asyncio.CancelledError: + generator._set_cancelled() + self.cancelled.emit(generator) + raise + + + class QtListener: def __init__(self, loop, queue) -> None: self.queue = queue @@ -215,10 +318,10 @@ async def aonce(self, timeout=None): class QtRunner(KoilRunner, QtCore.QObject): - started = QtCore.Signal() - errored = QtCore.Signal(Exception) - cancelled = QtCore.Signal() - returned = QtCore.Signal(object) + started: QtCore.Signal = QtCore.Signal() + errored: QtCore.Signal = QtCore.Signal(Exception) + cancelled: QtCore.Signal = QtCore.Signal() + returned: QtCore.Signal = QtCore.Signal(object) _returnedwithoutcontext = QtCore.Signal(object, object) def __init__(self, *args, **kwargs): @@ -342,7 +445,7 @@ def qtgenerator_to_async(func): class UnkoiledQt(Protocol): errored: QtCore.Signal - cancelled: QtCore.Signal() + cancelled: QtCore.Signal yielded: QtCore.Signal done: QtCore.Signal returned: QtCore.Signal @@ -367,7 +470,7 @@ def run(self, *args, **kwargs) -> KoilFuture: class KoilQt(Protocol): errored: QtCore.Signal - cancelled: QtCore.Signal() + cancelled: QtCore.Signal yielded: QtCore.Signal done: QtCore.Signal returned: QtCore.Signal @@ -476,3 +579,18 @@ def create_qt_koil(parent, auto_enter: bool = True) -> QtKoil: if auto_enter: koil.enter() return koil + + + + +class KoiledQtMixin(QtCore.QObject): + + + def __init__(self, parent=None): + super().__init__(parent) + self.koil = QtKoil(parent=self) + self.koil.enter() + + def __del__(self): + self.koil.__exit__(None, None, None) + self.koil = None \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 038d28e..b665e8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "koil" -version = "0.3.6" +version = "1.0.0" readme = "README.md" description = "Async for a sync world" authors = ["jhnnsrs "] diff --git a/tests/test_qtwidget_modern.py b/tests/test_qtwidget_modern.py new file mode 100644 index 0000000..efce2c4 --- /dev/null +++ b/tests/test_qtwidget_modern.py @@ -0,0 +1,269 @@ +import asyncio +from PyQt5 import QtWidgets, QtCore +from koil.qt import QtFuture, QtGenerator, QtKoil, QtKoilMixin, KoiledQtMixin +import contextvars +import pytest +from koil.qt import unkoilqt, koilqt, async_generator_to_qt, async_to_qt + +x = contextvars.ContextVar("x") + + +async def sleep_and_resolve(): + await asyncio.sleep(0.1) + return 1 + + +async def sleep_and_raise(): + await asyncio.sleep(0.1) + raise Exception("Task is done!") + + +async def sleep_and_use_context(): + await asyncio.sleep(0.1) + return x.get() + 1 + + +async def sleep_and_yield(times=5): + for i in range(times): + await asyncio.sleep(0.1) + yield i + + +class KoiledWidget(QtWidgets.QWidget, QtKoilMixin): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.button_greet = QtWidgets.QPushButton("Greet") + self.greet_label = QtWidgets.QLabel("") + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.button_greet) + layout.addWidget(self.greet_label) + + self.setLayout(layout) + + self.button_greet.clicked.connect(self.greet) + + def greet(self): + self.greet_label.setText("Hello!") + + +class KoiledInterferingWidget(QtWidgets.QWidget, KoiledQtMixin): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.call_task_button = QtWidgets.QPushButton("Call Task") + self.call_gen_button = QtWidgets.QPushButton("Call Generator") + self.call_raise_button = QtWidgets.QPushButton("Call Raise") + self.call_context_button = QtWidgets.QPushButton("Call Context") + + self.sleep_and_resolve_task = async_to_qt(sleep_and_resolve) + self.sleep_and_resolve_task.returned.connect(self.task_finished) + + self.sleep_and_use_context_task = async_to_qt(sleep_and_use_context) + self.sleep_and_use_context_task.returned.connect(self.task_finished) + + self.sleep_and_yield_task = async_generator_to_qt(sleep_and_yield) + self.sleep_and_yield_task.yielded.connect(self.task_finished) + + self.sleep_and_raise_task = async_to_qt(sleep_and_raise) + self.sleep_and_resolve_task.returned.connect(self.task_finished) + + self.greet_label = QtWidgets.QLabel("") + self.value = None + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.call_task_button) + layout.addWidget(self.call_gen_button) + layout.addWidget(self.call_context_button) + layout.addWidget(self.greet_label) + + self.setLayout(layout) + + self.call_task_button.clicked.connect(self.call_task) + self.call_gen_button.clicked.connect(self.call_gen) + self.call_context_button.clicked.connect(self.call_context) + self.call_raise_button.clicked.connect(self.call_raise) + + def call_task(self): + self.sleep_and_resolve_task.run() + + def call_gen(self): + self.sleep_and_yield_task.run() + + def call_context(self): + self.sleep_and_use_context_task.run() + + def call_raise(self): + self.sleep_and_raise_task.run() + + def task_finished(self, int): + self.value = int + self.greet_label.setText("Hello!") + + +class KoiledInterferingFutureWidget(QtWidgets.QWidget, KoiledQtMixin): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.do_me = koilqt(self.in_qt_task, autoresolve=False) + + self.my_coro_task = async_to_qt(self.call_coro) + self.my_coro_task.returned.connect(self.task_finished) + + self.task_was_run = False + self.coroutine_was_run = False + + self.call_task_button = QtWidgets.QPushButton("Call Task") + self.greet_label = QtWidgets.QLabel("") + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.call_task_button) + layout.addWidget(self.greet_label) + + self.setLayout(layout) + + self.call_task_button.clicked.connect(self.call_task) + + def in_qt_task(self, future: QtFuture): + self.task_was_run = True + future.resolve("called") + + def call_task(self): + self.my_coro_task.run() + + def task_finished(self): + self.greet_label.setText("Hello!") + + async def call_coro(self): + await self.do_me() + self.coroutine_was_run = True + + +class KoiledGeneratorWidget(QtWidgets.QWidget, KoiledQtMixin): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.my_coro_task = unkoilqt(self.call_coro) + self.my_coro_task.returned.connect(self.task_finished) + + self.task_was_run = False + self.coroutine_was_run = False + self.coroutine_finished = False + + self.call_task_button = QtWidgets.QPushButton("Call Task") + self.greet_label = QtWidgets.QLabel("") + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.call_task_button) + layout.addWidget(self.greet_label) + + self.setLayout(layout) + + self.call_task_button.clicked.connect(self.call_task) + + def in_qt_task(self, future: QtFuture): + self.task_was_run = True + future.resolve("called") + + def call_task(self): + self.my_coro_task.run() + + def task_finished(self): + self.greet_label.setText("Hello!") + + async def call_coro(self): + self.qt_generator = QtGenerator() + + async for x in self.qt_generator: + self.coroutine_was_run = True + + self.coroutine_finished = True + + await self.do_me.acall() + self.coroutine_was_run = True + print("nana") + + +@pytest.mark.qt +def test_koil_qt_no_interference(qtbot): + """Tests if just adding koil interferes with normal + qtpy widgets. + + Args: + qtbot (_type_): _description_ + """ + widget = KoiledWidget() + qtbot.addWidget(widget) + + # click in the Greet button and make sure it updates the appropriate label + qtbot.mouseClick(widget.button_greet, QtCore.Qt.LeftButton) + + assert widget.greet_label.text() == "Hello!" + + +@pytest.mark.qt +def test_koil_qt_call_task(qtbot): + """Tests if we can call a task from a koil widget.""" + widget = KoiledInterferingWidget() + qtbot.addWidget(widget) + + # click in the Greet button and make sure it updates the appropriate label + with qtbot.waitSignal(widget.sleep_and_resolve_task.returned): + qtbot.mouseClick(widget.call_task_button, QtCore.Qt.LeftButton) + + +@pytest.mark.qt +def test_call_gen(qtbot): + """Tests if we can call a task from a koil widget.""" + widget = KoiledInterferingWidget() + qtbot.addWidget(widget) + + # click in the Greet button and make sure it updates the appropriate label + with qtbot.waitSignal(widget.sleep_and_yield_task.yielded, timeout=1000): + + qtbot.mouseClick(widget.call_gen_button, QtCore.Qt.LeftButton) + + +@pytest.mark.qt +def test_call_future(qtbot): + """Tests if we can call a task from a koil widget.""" + widget = KoiledInterferingFutureWidget() + qtbot.addWidget(widget) + + # click in the Greet button and make sure it updates the appropriate label + with qtbot.waitSignal(widget.my_coro_task.returned, timeout=1000): + + qtbot.mouseClick(widget.call_task_button, QtCore.Qt.LeftButton) + + assert widget.task_was_run is True + assert widget.coroutine_was_run is True + + +@pytest.mark.qt +def test_call_raise(qtbot): + """Tests if we can call a task from a koil widget.""" + widget = KoiledInterferingWidget() + qtbot.addWidget(widget) + + # click in the Greet button and make sure it updates the appropriate label + + with qtbot.waitSignal(widget.sleep_and_raise_task.errored, timeout=1000) as b: + qtbot.mouseClick(widget.call_raise_button, QtCore.Qt.LeftButton) + + assert isinstance(b.args[0], Exception) + + +@pytest.mark.qt +def test_context(qtbot): + """Tests if we can call a task from a koil widget.""" + widget = KoiledInterferingWidget() + qtbot.addWidget(widget) + + x.set(5) + # click in the Greet button and make sure it updates the appropriate label + + with qtbot.waitSignal( + widget.sleep_and_use_context_task.returned, timeout=1000 + ) as b: + qtbot.mouseClick(widget.call_context_button, QtCore.Qt.LeftButton) + + assert b.args[0] == 6