Skip to content

Commit

Permalink
new qt api (futures/generators)
Browse files Browse the repository at this point in the history
  • Loading branch information
jhnnsrs committed Sep 23, 2024
1 parent 85561bf commit 6707ae0
Show file tree
Hide file tree
Showing 5 changed files with 405 additions and 26 deletions.
15 changes: 5 additions & 10 deletions koil/composition/base.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -7,28 +7,27 @@


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

def _repr_html_inline_(self):
return f"<table><tr><td>allow sync in async</td><td>{self.sync_in_async}</td></tr><tr><td>uvified</td><td>{self.uvify}</td></tr></table>"

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: ...

Expand All @@ -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):
Expand Down
3 changes: 0 additions & 3 deletions koil/composition/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
142 changes: 130 additions & 12 deletions koil/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)

Expand All @@ -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
Expand All @@ -74,15 +124,16 @@ 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)

def stop(self):
self.loop.call_soon_threadsafe(self.aioqueue.put_nowait, KoilStopIteration())


def __aiter__(self):
return self

Expand All @@ -94,6 +145,7 @@ async def __anext__(self):
if isinstance(res, Exception):
raise res
except asyncio.CancelledError:
self.cancelled.emit()
raise StopAsyncIteration
return res

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <jhnnsrs@gmail.com>"]
Expand Down
Loading

0 comments on commit 6707ae0

Please sign in to comment.