From 350397b51794a9771a09083ea4dbd51a501ebb72 Mon Sep 17 00:00:00 2001 From: Gorshkov Nikolay Date: Sun, 13 Jul 2025 22:04:05 +0500 Subject: [PATCH 1/6] wip: signals --- peewee_async/signals.py | 80 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 peewee_async/signals.py diff --git a/peewee_async/signals.py b/peewee_async/signals.py new file mode 100644 index 0000000..41a5a79 --- /dev/null +++ b/peewee_async/signals.py @@ -0,0 +1,80 @@ +""" +Provide django-style hooks for model events. +""" +from peewee_async import AioModel as _Model + + +class Signal(object): + def __init__(self): + self._flush() + + def _flush(self): + self._receivers = set() + self._receiver_list = [] + + def connect(self, receiver, name=None, sender=None): + name = name or receiver.__name__ + key = (name, sender) + if key not in self._receivers: + self._receivers.add(key) + self._receiver_list.append((name, receiver, sender)) + else: + raise ValueError('receiver named %s (for sender=%s) already ' + 'connected' % (name, sender or 'any')) + + def disconnect(self, receiver=None, name=None, sender=None): + if receiver: + name = name or receiver.__name__ + if not name: + raise ValueError('a receiver or a name must be provided') + + key = (name, sender) + if key not in self._receivers: + raise ValueError('receiver named %s for sender=%s not found.' % + (name, sender or 'any')) + + self._receivers.remove(key) + self._receiver_list = [(n, r, s) for n, r, s in self._receiver_list + if (n, s) != key] + + def __call__(self, name=None, sender=None): + def decorator(fn): + self.connect(fn, name, sender) + return fn + return decorator + + async def send(self, instance, *args, **kwargs): + sender = type(instance) + responses = [] + for n, r, s in self._receiver_list: + if s is None or isinstance(instance, s): + responses.append((r, await r(sender, instance, *args, **kwargs))) + return responses + + +aio_pre_save = Signal() +aio_post_save = Signal() +aio_pre_delete = Signal() +aio_post_delete = Signal() +pre_init = Signal() # can't be async ! + + +class AioModel(_Model): + + def __init__(self, *args, **kwargs): + super(AioModel, self).__init__(*args, **kwargs) + pre_init.send(self) + + async def aio_save(self, *args, **kwargs): + pk_value = self._pk if self._meta.primary_key else True + created = kwargs.get('force_insert', False) or not bool(pk_value) + await aio_pre_save.send(self, created=created) + ret = await super(AioModel, self).aio_save(*args, **kwargs) + await aio_post_save.send(self, created=created) + return ret + + async def aio_delete_instance(self, *args, **kwargs): + await aio_pre_delete.send(self) + ret = await super(AioModel, self).aio_delete_instance(*args, **kwargs) + await aio_post_delete.send(self) + return ret From ba6548426509712c7300bb37b96c6ff74b95d1b9 Mon Sep 17 00:00:00 2001 From: Gorshkov Nikolay Date: Sun, 13 Jul 2025 22:16:31 +0500 Subject: [PATCH 2/6] add some typing --- peewee_async/aio_model.py | 4 ++-- peewee_async/signals.py | 21 +++++++++++---------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/peewee_async/aio_model.py b/peewee_async/aio_model.py index d19e5f7..f588d16 100644 --- a/peewee_async/aio_model.py +++ b/peewee_async/aio_model.py @@ -5,7 +5,7 @@ from .result_wrappers import fetch_models from .utils import CursorProtocol from typing_extensions import Self -from typing import Tuple, List, Any, cast, Optional, Dict, Union +from typing import Literal, Tuple, List, Any, cast, Optional, Dict, Union async def aio_prefetch(sq: Any, *subqueries: Any, prefetch_type: PREFETCH_TYPE = PREFETCH_TYPE.WHERE) -> Any: @@ -281,7 +281,7 @@ async def aio_delete_instance(self, recursive: bool = False, delete_nullable: bo await model.delete().where(query).aio_execute() return cast(int, await type(self).delete().where(self._pk_expr()).aio_execute()) - async def aio_save(self, force_insert: bool = False, only: Any =None) -> int: + async def aio_save(self, force_insert: bool = False, only: Any =None) -> Union[int, Literal[False]]: """ Async version of **peewee.Model.save** diff --git a/peewee_async/signals.py b/peewee_async/signals.py index 41a5a79..d23df19 100644 --- a/peewee_async/signals.py +++ b/peewee_async/signals.py @@ -2,17 +2,18 @@ Provide django-style hooks for model events. """ from peewee_async import AioModel as _Model +from typing import Union, Literal, Any class Signal(object): - def __init__(self): + def __init__(self) -> None: self._flush() - def _flush(self): + def _flush(self)-> None: self._receivers = set() self._receiver_list = [] - def connect(self, receiver, name=None, sender=None): + def connect(self, receiver, name=None, sender=None) -> None: name = name or receiver.__name__ key = (name, sender) if key not in self._receivers: @@ -22,7 +23,7 @@ def connect(self, receiver, name=None, sender=None): raise ValueError('receiver named %s (for sender=%s) already ' 'connected' % (name, sender or 'any')) - def disconnect(self, receiver=None, name=None, sender=None): + def disconnect(self, receiver=None, name=None, sender=None) -> None: if receiver: name = name or receiver.__name__ if not name: @@ -61,20 +62,20 @@ async def send(self, instance, *args, **kwargs): class AioModel(_Model): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super(AioModel, self).__init__(*args, **kwargs) pre_init.send(self) - async def aio_save(self, *args, **kwargs): + async def aio_save(self, force_insert: bool = False, only: Any = None) -> Union[int, Literal[False]]: pk_value = self._pk if self._meta.primary_key else True - created = kwargs.get('force_insert', False) or not bool(pk_value) + created = force_insert or not bool(pk_value) await aio_pre_save.send(self, created=created) - ret = await super(AioModel, self).aio_save(*args, **kwargs) + ret = await super(AioModel, self).aio_save(force_insert, only) await aio_post_save.send(self, created=created) return ret - async def aio_delete_instance(self, *args, **kwargs): + async def aio_delete_instance(self, recursive: bool = False, delete_nullable: bool = False) -> int: await aio_pre_delete.send(self) - ret = await super(AioModel, self).aio_delete_instance(*args, **kwargs) + ret = await super(AioModel, self).aio_delete_instance(recursive, delete_nullable) await aio_post_delete.send(self) return ret From 2d5013c36fd12a446aec62227eeb2f08280f1c46 Mon Sep 17 00:00:00 2001 From: Gorshkov Nikolay Date: Thu, 24 Jul 2025 14:14:06 +0500 Subject: [PATCH 3/6] refactor --- peewee_async/signals.py | 53 +++++------------------------------------ tests/models.py | 11 ++++++++- tests/test_signals.py | 19 +++++++++++++++ 3 files changed, 35 insertions(+), 48 deletions(-) create mode 100644 tests/test_signals.py diff --git a/peewee_async/signals.py b/peewee_async/signals.py index d23df19..6035a9e 100644 --- a/peewee_async/signals.py +++ b/peewee_async/signals.py @@ -1,49 +1,8 @@ -""" -Provide django-style hooks for model events. -""" from peewee_async import AioModel as _Model from typing import Union, Literal, Any +from playhouse.signals import Signal - -class Signal(object): - def __init__(self) -> None: - self._flush() - - def _flush(self)-> None: - self._receivers = set() - self._receiver_list = [] - - def connect(self, receiver, name=None, sender=None) -> None: - name = name or receiver.__name__ - key = (name, sender) - if key not in self._receivers: - self._receivers.add(key) - self._receiver_list.append((name, receiver, sender)) - else: - raise ValueError('receiver named %s (for sender=%s) already ' - 'connected' % (name, sender or 'any')) - - def disconnect(self, receiver=None, name=None, sender=None) -> None: - if receiver: - name = name or receiver.__name__ - if not name: - raise ValueError('a receiver or a name must be provided') - - key = (name, sender) - if key not in self._receivers: - raise ValueError('receiver named %s for sender=%s not found.' % - (name, sender or 'any')) - - self._receivers.remove(key) - self._receiver_list = [(n, r, s) for n, r, s in self._receiver_list - if (n, s) != key] - - def __call__(self, name=None, sender=None): - def decorator(fn): - self.connect(fn, name, sender) - return fn - return decorator - +class AioSignal(Signal): async def send(self, instance, *args, **kwargs): sender = type(instance) responses = [] @@ -53,10 +12,10 @@ async def send(self, instance, *args, **kwargs): return responses -aio_pre_save = Signal() -aio_post_save = Signal() -aio_pre_delete = Signal() -aio_post_delete = Signal() +aio_pre_save = AioSignal() +aio_post_save = AioSignal() +aio_pre_delete = AioSignal() +aio_post_delete = AioSignal() pre_init = Signal() # can't be async ! diff --git a/tests/models.py b/tests/models.py index 3cdce35..b1ece3b 100644 --- a/tests/models.py +++ b/tests/models.py @@ -2,6 +2,7 @@ import peewee import peewee_async +import peewee_async.signals class TestModel(peewee_async.AioModel): @@ -61,7 +62,15 @@ class IntegerTestModel(peewee_async.AioModel): num = peewee.IntegerField() +class TestSignalModel(peewee_async.signals.AioModel): + __test__ = False # disable pytest warnings + text = peewee.CharField(max_length=100) + + def __str__(self) -> str: + return '<%s id=%s> %s' % (self.__class__.__name__, self.id, self.text) + + ALL_MODELS = ( TestModel, UUIDTestModel, TestModelAlpha, TestModelBeta, TestModelGamma, - CompositeTestModel, IntegerTestModel + CompositeTestModel, IntegerTestModel, TestSignalModel ) diff --git a/tests/test_signals.py b/tests/test_signals.py new file mode 100644 index 0000000..a67e9ce --- /dev/null +++ b/tests/test_signals.py @@ -0,0 +1,19 @@ +import uuid + +from peewee_async.databases import AioDatabase +from tests.conftest import dbs_all, dbs_postgres +from tests.models import TestSignalModel +from tests.utils import model_has_fields +from peewee_async.signals import aio_pre_save + + + + +@dbs_all +async def test_aio_pre_save(db: AioDatabase) -> None: + + @aio_pre_save(sender=TestSignalModel) + async def on_save_handler(model_class, instance, created): + print(model_class, instance, created) + + await TestSignalModel.aio_create(text="text") \ No newline at end of file From 10c07fd1052ec2c93a3184515d045d1dfa41ecb2 Mon Sep 17 00:00:00 2001 From: Gorshkov Nikolay Date: Fri, 25 Jul 2025 15:26:47 +0500 Subject: [PATCH 4/6] tests for signals --- peewee_async/signals.py | 2 +- tests/models.py | 35 +++++++++-------- tests/test_signals.py | 84 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 94 insertions(+), 27 deletions(-) diff --git a/peewee_async/signals.py b/peewee_async/signals.py index 6035a9e..f3e65ec 100644 --- a/peewee_async/signals.py +++ b/peewee_async/signals.py @@ -3,7 +3,7 @@ from playhouse.signals import Signal class AioSignal(Signal): - async def send(self, instance, *args, **kwargs): + async def send(self, instance: "AioModel", *args: Any, **kwargs: Any) -> list[tuple[Any, Any]]: sender = type(instance) responses = [] for n, r, s in self._receiver_list: diff --git a/tests/models.py b/tests/models.py index b1ece3b..69a7517 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,14 +1,16 @@ +from email.policy import default import uuid -import peewee +import peewee as pw import peewee_async import peewee_async.signals +import datetime as dt class TestModel(peewee_async.AioModel): __test__ = False # disable pytest warnings - text = peewee.CharField(max_length=100, unique=True) - data = peewee.TextField(default='') + text = pw.CharField(max_length=100, unique=True) + data = pw.TextField(default='') def __str__(self) -> str: return '<%s id=%s> %s' % (self.__class__.__name__, self.id, self.text) @@ -16,7 +18,7 @@ def __str__(self) -> str: class TestModelAlpha(peewee_async.AioModel): __test__ = False - text = peewee.CharField() + text = pw.CharField() def __str__(self) -> str: return '<%s id=%s> %s' % (self.__class__.__name__, self.id, self.text) @@ -24,8 +26,8 @@ def __str__(self) -> str: class TestModelBeta(peewee_async.AioModel): __test__ = False - alpha = peewee.ForeignKeyField(TestModelAlpha, backref='betas') - text = peewee.CharField() + alpha = pw.ForeignKeyField(TestModelAlpha, backref='betas') + text = pw.CharField() def __str__(self) -> str: return '<%s id=%s> %s' % (self.__class__.__name__, self.id, self.text) @@ -33,16 +35,16 @@ def __str__(self) -> str: class TestModelGamma(peewee_async.AioModel): __test__ = False - text = peewee.CharField() - beta = peewee.ForeignKeyField(TestModelBeta, backref='gammas') + text = pw.CharField() + beta = pw.ForeignKeyField(TestModelBeta, backref='gammas') def __str__(self) -> str: return '<%s id=%s> %s' % (self.__class__.__name__, self.id, self.text) class UUIDTestModel(peewee_async.AioModel): - id = peewee.UUIDField(primary_key=True, default=uuid.uuid4) - text = peewee.CharField() + id = pw.UUIDField(primary_key=True, default=uuid.uuid4) + text = pw.CharField() def __str__(self) -> str: return '<%s id=%s> %s' % (self.__class__.__name__, self.id, self.text) @@ -50,24 +52,21 @@ def __str__(self) -> str: class CompositeTestModel(peewee_async.AioModel): """A simple "through" table for many-to-many relationship.""" - task_id = peewee.IntegerField() - product_type = peewee.CharField() + task_id = pw.IntegerField() + product_type = pw.CharField() class Meta: - primary_key = peewee.CompositeKey('task_id', 'product_type') + primary_key = pw.CompositeKey('task_id', 'product_type') class IntegerTestModel(peewee_async.AioModel): __test__ = False # disable pytest warnings - num = peewee.IntegerField() + num = pw.IntegerField() class TestSignalModel(peewee_async.signals.AioModel): __test__ = False # disable pytest warnings - text = peewee.CharField(max_length=100) - - def __str__(self) -> str: - return '<%s id=%s> %s' % (self.__class__.__name__, self.id, self.text) + text = pw.CharField(max_length=100) ALL_MODELS = ( diff --git a/tests/test_signals.py b/tests/test_signals.py index a67e9ce..18398b2 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -1,19 +1,87 @@ -import uuid +from contextlib import contextmanager +from typing import Any, Callable, Coroutine, Iterator from peewee_async.databases import AioDatabase -from tests.conftest import dbs_all, dbs_postgres +from tests.conftest import dbs_all from tests.models import TestSignalModel -from tests.utils import model_has_fields -from peewee_async.signals import aio_pre_save +from peewee_async.signals import AioModel, aio_pre_save, aio_post_save, aio_post_delete, aio_pre_delete, AioSignal, pre_init + +@contextmanager +def _connect(signal: AioSignal , receiver: Callable[..., Coroutine[Any, Any, Any]], sender: type[AioModel]) -> Iterator[None]: + signal.connect(receiver=receiver, sender=sender) + yield + signal.disconnect(receiver=receiver, sender=sender) + + @dbs_all async def test_aio_pre_save(db: AioDatabase) -> None: - @aio_pre_save(sender=TestSignalModel) - async def on_save_handler(model_class, instance, created): - print(model_class, instance, created) + + async def on_save_handler(model_class: type[TestSignalModel], instance: TestSignalModel, created: bool) -> None: + assert await TestSignalModel.select().aio_exists() is False + assert model_class is TestSignalModel + assert isinstance(instance, TestSignalModel) + assert created + + with _connect(aio_pre_save, receiver=on_save_handler, sender=TestSignalModel): + await TestSignalModel.aio_create(text="aio_create") + + +@dbs_all +async def test_aio_post_save(db: AioDatabase) -> None: + + + async def on_save_handler(model_class: type[TestSignalModel], instance: TestSignalModel, created: bool) -> None: + assert await TestSignalModel.select().aio_exists() is True + assert model_class is TestSignalModel + assert isinstance(instance, TestSignalModel) + assert created + + with _connect(aio_post_save, receiver=on_save_handler, sender=TestSignalModel): + await TestSignalModel.aio_create(text="aio_create") + + +@dbs_all +async def test_aio_pre_delete(db: AioDatabase) -> None: + + t = await TestSignalModel.aio_create(text="aio_create") + + async def on_delete_handler(model_class: type[TestSignalModel], instance: TestSignalModel) -> None: + assert await TestSignalModel.select().aio_exists() is True + assert model_class is TestSignalModel + assert isinstance(instance, TestSignalModel) + + with _connect(aio_pre_delete, receiver=on_delete_handler, sender=TestSignalModel): + await t.aio_delete_instance() + + +@dbs_all +async def test_aio_post_delete(db: AioDatabase) -> None: + + t = await TestSignalModel.aio_create(text="aio_create") + + async def on_delete_handler(model_class: type[TestSignalModel], instance: TestSignalModel) -> None: + assert await TestSignalModel.select().aio_exists() is False + assert model_class is TestSignalModel + assert isinstance(instance, TestSignalModel) + + with _connect(aio_post_delete, receiver=on_delete_handler, sender=TestSignalModel): + await t.aio_delete_instance() + + +@dbs_all +def test_pre_init(db: AioDatabase) -> None: + + def on_init_handler(model_class: type[TestSignalModel], instance: TestSignalModel) -> None: + assert model_class is TestSignalModel + assert instance.text == "text" + + pre_init.connect(receiver=on_init_handler, sender=TestSignalModel) + + TestSignalModel(text="text") - await TestSignalModel.aio_create(text="text") \ No newline at end of file + pre_init.disconnect(receiver=on_init_handler, sender=TestSignalModel) \ No newline at end of file From 45c7dc3e4a395da9abfe19caffdfa3730cbb834c Mon Sep 17 00:00:00 2001 From: Gorshkov Nikolay Date: Fri, 25 Jul 2025 15:29:22 +0500 Subject: [PATCH 5/6] fix --- tests/test_signals.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_signals.py b/tests/test_signals.py index 18398b2..8ed3ba3 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -7,9 +7,6 @@ from peewee_async.signals import AioModel, aio_pre_save, aio_post_save, aio_post_delete, aio_pre_delete, AioSignal, pre_init - - - @contextmanager def _connect(signal: AioSignal , receiver: Callable[..., Coroutine[Any, Any, Any]], sender: type[AioModel]) -> Iterator[None]: signal.connect(receiver=receiver, sender=sender) From c110a7aca1945c30113a6de78096e344ca69b85c Mon Sep 17 00:00:00 2001 From: Gorshkov Nikolay Date: Sat, 2 Aug 2025 20:52:36 +0500 Subject: [PATCH 6/6] add docs --- docs/index.rst | 1 + docs/peewee_async/signals.rst | 43 +++++++++++++++++++++++++++++++++++ tests/models.py | 1 - 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 docs/peewee_async/signals.rst diff --git a/docs/index.rst b/docs/index.rst index cb2f0df..181d2dc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -36,6 +36,7 @@ Contents peewee_async/api peewee_async/connection peewee_async/transaction + peewee_async/signals peewee_async/examples Indices and tables diff --git a/docs/peewee_async/signals.rst b/docs/peewee_async/signals.rst new file mode 100644 index 0000000..bbb405f --- /dev/null +++ b/docs/peewee_async/signals.rst @@ -0,0 +1,43 @@ +Signal support +==================== + + `Signal support`_ has been backported from the original peewee with a few differences. Models with hooks for signals are provided in + ``peewee_async.signals``. To use the signals, you will need all of your project's + models to be a subclass of ``peewee_async.signals.AioModel``, which overrides the + necessary methods to provide support for the various signals. A handler for any signal except ``pre_init`` should be a coroutine function. For obvious reasons + ``pre_init`` signal handler can be only a synchronious function. + +.. code-block:: python + + from peewee_async.signals import AioModel, aio_post_save + + + class MyModel(AioModel): + data = IntegerField() + + @aio_post_save(sender=MyModel) + async def on_save_handler(model_class, instance, created): + await save_in_history_table(instance.data) + + +The following signals are provided: + +``aio_pre_save`` + Called immediately before an object is saved to the database. Provides an + additional keyword argument ``created``, indicating whether the model is being + saved for the first time or updated. +``aio_post_save`` + Called immediately after an object is saved to the database. Provides an + additional keyword argument ``created``, indicating whether the model is being + saved for the first time or updated. +``aio_pre_delete`` + Called immediately before an object is deleted from the database when :py:meth:`Model.aio_delete_instance` + is used. +``aio_post_delete`` + Called immediately after an object is deleted from the database when :py:meth:`Model.aio_delete_instance` + is used. +``pre_init`` + Called when a model class is first instantiated. Can not be async. + + +.. _Signal support: https://docs.peewee-orm.com/en/latest/peewee/playhouse.html#signal-support diff --git a/tests/models.py b/tests/models.py index 69a7517..d7240b4 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,4 +1,3 @@ -from email.policy import default import uuid import peewee as pw