From 30d5686cc22f701c18cff13fc50c6dccd5f0d479 Mon Sep 17 00:00:00 2001 From: tarsil Date: Wed, 30 Aug 2023 13:07:44 +0100 Subject: [PATCH 1/2] Add signals to Saffier --- docs/signals.md | 235 ++++++++++++++++++ docs_src/signals/custom.py | 17 ++ docs_src/signals/disconnect.py | 10 + docs_src/signals/logic.py | 17 ++ docs_src/signals/receiver/disconnect.py | 23 ++ docs_src/signals/receiver/model.py | 14 ++ docs_src/signals/receiver/multiple.py | 21 ++ .../signals/receiver/multiple_receivers.py | 27 ++ docs_src/signals/receiver/post_multiple.py | 20 ++ docs_src/signals/receiver/post_save.py | 16 ++ docs_src/signals/register.py | 30 +++ mkdocs.yml | 1 + saffier/__init__.py | 2 + saffier/core/db/models/base.py | 15 +- saffier/core/db/models/metaclasses.py | 27 ++ saffier/core/db/models/model.py | 16 +- saffier/core/db/querysets/base.py | 11 + saffier/core/signals/__init__.py | 13 + saffier/core/signals/handlers.py | 67 +++++ saffier/core/signals/signal.py | 68 +++++ saffier/exceptions.py | 4 + saffier/utils/__init__.py | 0 saffier/utils/inspect.py | 13 + tests/signals/test_signals.py | 209 ++++++++++++++++ 24 files changed, 874 insertions(+), 2 deletions(-) create mode 100644 docs/signals.md create mode 100644 docs_src/signals/custom.py create mode 100644 docs_src/signals/disconnect.py create mode 100644 docs_src/signals/logic.py create mode 100644 docs_src/signals/receiver/disconnect.py create mode 100644 docs_src/signals/receiver/model.py create mode 100644 docs_src/signals/receiver/multiple.py create mode 100644 docs_src/signals/receiver/multiple_receivers.py create mode 100644 docs_src/signals/receiver/post_multiple.py create mode 100644 docs_src/signals/receiver/post_save.py create mode 100644 docs_src/signals/register.py create mode 100644 saffier/core/signals/__init__.py create mode 100644 saffier/core/signals/handlers.py create mode 100644 saffier/core/signals/signal.py create mode 100644 saffier/utils/__init__.py create mode 100644 saffier/utils/inspect.py create mode 100644 tests/signals/test_signals.py diff --git a/docs/signals.md b/docs/signals.md new file mode 100644 index 0000000..ef0349b --- /dev/null +++ b/docs/signals.md @@ -0,0 +1,235 @@ +# Signals + +Sometimes you might want to *listen* to a model event upon the save, meaning, you want to do a +specific action when something happens in the models. + +Django for instance has this mechanism called `Signals` which can be very helpful for these cases +and to perform extra operations once an action happens in your model. + +Other ORMs did a similar approach to this and a fantastic one was Ormar which took the Django approach +to its own implementation. + +Saffier being the way it is designed, got the inspiration from both of these approaches and also +supports the `Signal`. + +## What are Signals + +Signals are a mechanism used to trigger specific actions upon a given type of event happens within +the Saffier models. + +The same way Django approaches signals in terms of registration, Saffier does it in the similar fashion. + +## Default signals + +Saffier has default receivers for each model created within the ecosystem. Those can be already used +out of the box by you at any time. + +There are also [custom signals](#custom-signals) in case you want an "extra" besides the defaults +provided. + +### How to use them + +The signals are inside the `saffier.core.signals` and to import them, simply run: + +``` python +from saffier.core.signals import ( + post_delete, + post_save, + post_update, + pre_delete, + pre_save, + pre_update, +) +``` + +#### pre_save + +The `pre_save` is used when a model is about to be saved and triggered on `Model.save()` and +`Model.query.create` functions. + +```python +pre_save(send: Type["Model"], instance: "Model") +``` + +#### post_save + +The `post_save` is used after the model is already created and stored in the database, meaning, +when an instance already exists after `save`. This signal is triggered on `Model.save()` and +`Model.query.create` functions. + +```python +post_save(send: Type["Model"], instance: "Model") +``` + +#### pre_update + +The `pre_update` is used when a model is about to receive the updates and triggered on `Model.update()` +and `Model.query.update` functions. + +```python +pre_update(send: Type["Model"], instance: "Model") +``` + +#### post_update + +The `post_update` is used when a model **already performed the updates** and triggered on `Model.update()` +and `Model.query.update` functions. + +```python +post_update(send: Type["Model"], instance: "Model") +``` + +#### pre_delete + +The `pre_delete` is used when a model is about to be deleted and triggered on `Model.delete()` +and `Model.query.delete` functions. + +```python +pre_delete(send: Type["Model"], instance: "Model") +``` + +#### post_delete + +The `post_update` is used when a model **is already deleted** and triggered on `Model.delete()` +and `Model.query.delete` functions. + +```python +post_update(send: Type["Model"], instance: "Model") +``` + +## Receiver + +The receiver is the function or action that you want to perform upon a signal being triggered, +in other words, **it is what is listening to a given event**. + +Let us see an example. Given the following model. + +```python +{!> ../docs_src/signals/receiver/model.py !} +``` + +You can set a trigger to send an email to the registered user upon the creation of the record by +using the `post_save` signal. The reason for the `post_save` it it because the notification must +be sent **after** the creation of the record and not before. If it was before, the `pre_save` would +be the one to use. + +```python hl_lines="11-12" +{!> ../docs_src/signals/receiver/post_save.py !} +``` + +As you can see, the `post_save` decorator is pointing the `User` model, meaning, it is "listing" +to events on that same model. + +This is called **receiver**. + +You can use any of the [default signals](#default-signals) available or even create your own +[custom signal](#custom-signals). + +### Requirements + +When defining your function or `receiver` it must have the following requirements: + +* Must be a **callable**. +* Must have `sender` argument as first parameter which corresponds to the model of the sending object. +* Must have ****kwargs** argument as parameter as each model can change at any given time. +* Must be `async` because Saffier model operations are awaited. + +### Multiple receivers + +What if you want to use the same receiver but for multiple models? Let us now add an extra `Profile` +model. + +```python +{!> ../docs_src/signals/receiver/multiple.py !} +``` + +The way you define the receiver for both can simply be achieved like this: + +```python hl_lines="11" +{!> ../docs_src/signals/receiver/post_multiple.py !} +``` + +This way you can match and do any custom logic without the need of replicating yourself too much and +keeping your code clean and consistent. + +### Multiple receivers for the same model + +What if now you want to have more than one receiver for the same model? Practically you would put all +in one place but you might want to do something else entirely and split those in multiple. + +You can easily achieve this like this: + +```python +{!> ../docs_src/signals/receiver/multiple_receivers.py !} +``` + +This will make sure that every receiver will execute the given defined action. + + +### Disconnecting receivers + +If you wish to disconnect the receiver and stop it from running for a fiven model, you can also +achieve this in a simple way. + +```python hl_lines="20 23" +{!> ../docs_src/signals/receiver/disconnect.py !} +``` + +## Custom Signals + +This is where things get interesting. A lot of time you might want to have your own `Signal` and +not relying only on the [default](#default-signals) ones and this perfectly natural and common. + +Saffier allows the custom signals to take place per your own design. + +Let us continue with the same example of the `User` model. + +```python +{!> ../docs_src/signals/receiver/model.py !} +``` + +Now you want to have a custom signal called `on_verify` specifically tailored for your `User` needs +and logic. + +So define it, you can simply do: + +```python hl_lines="17" +{!> ../docs_src/signals/custom.py !} +``` + +Yes, this simple. You simply need to add a new signal `on_verify` to the model signals and the +`User` model from now on has a new signal ready to be used. + +!!! Danger + Keep in mind **signals are class level type**, which means it will affect all of the derived + instances coming from it. Be mindful when creating a custom signal and its impacts. + +Now you want to create a custom functionality to be listened in your new Signal. + +```python hl_lines="21 30" +{!> ../docs_src/signals/register.py !} +``` + +Now not only you created the new receiver `trigger_notifications` but also connected it to the +the new `on_verify` signal. + +### How to use it + +Now it is time to use the signal in a custom logic, after all it was created to make sure it is +custom enough for the needs of the business logic. + +For simplification, the example below will be a very simple logic. + +```python hl_lines="17" +{!> ../docs_src/signals/logic.py !} +``` + +As you can see, the `on_verify`, it is only triggered if the user is verified and not anywhere else. + +### Disconnect the signal + +The process of disconnecting the signal is exactly the [same as before](#disconnecting-receivers). + +```python hl_lines="10" +{!> ../docs_src/signals/disconnect.py !} +``` diff --git a/docs_src/signals/custom.py b/docs_src/signals/custom.py new file mode 100644 index 0000000..cdba10a --- /dev/null +++ b/docs_src/signals/custom.py @@ -0,0 +1,17 @@ +import saffier + +database = saffier.Database("sqlite:///db.sqlite") +registry = saffier.Registry(database=database) + + +class User(saffier.Model): + id = saffier.BigIntegerField(primary_key=True) + name = saffier.CharField(max_length=255) + email = saffier.CharField(max_length=255) + + class Meta: + registry = registry + + +# Create the custom signal +User.meta.signals.on_verify = saffier.Signal() diff --git a/docs_src/signals/disconnect.py b/docs_src/signals/disconnect.py new file mode 100644 index 0000000..311d505 --- /dev/null +++ b/docs_src/signals/disconnect.py @@ -0,0 +1,10 @@ +async def trigger_notifications(sender, instance, **kwargs): + """ + Sends email and push notification + """ + send_email(instance.email) + send_push_notification(instance.email) + + +# Disconnect the given function +User.meta.signals.on_verify.disconnect(trigger_notifications) diff --git a/docs_src/signals/logic.py b/docs_src/signals/logic.py new file mode 100644 index 0000000..8fca4bd --- /dev/null +++ b/docs_src/signals/logic.py @@ -0,0 +1,17 @@ +async def create_user(**kwargs): + """ + Creates a user + """ + await User.query.create(**kwargs) + + +async def is_verified_user(id: int): + """ + Checks if user is verified and sends notification + if true. + """ + user = await User.query.get(pk=id) + + if user.is_verified: + # triggers the custom signal + await User.meta.signals.on_verify.send(sender=User, instance=user) diff --git a/docs_src/signals/receiver/disconnect.py b/docs_src/signals/receiver/disconnect.py new file mode 100644 index 0000000..c4f480f --- /dev/null +++ b/docs_src/signals/receiver/disconnect.py @@ -0,0 +1,23 @@ +from saffier.core.signals import post_save + + +def send_notification(email: str) -> None: + """ + Sends a notification to the user + """ + send_email_confirmation(email) + + +@post_save(User) +async def after_creation(sender, instance, **kwargs): + """ + Sends a notification to the user + """ + send_notification(instance.email) + + +# Disconnect the given function +User.meta.signals.post_save.disconnect(after_creation) + +# Signals are also exposed via instance +user.signals.post_save.disconnect(after_creation) diff --git a/docs_src/signals/receiver/model.py b/docs_src/signals/receiver/model.py new file mode 100644 index 0000000..0f2ef57 --- /dev/null +++ b/docs_src/signals/receiver/model.py @@ -0,0 +1,14 @@ +import saffier + +database = saffier.Database("sqlite:///db.sqlite") +registry = saffier.Registry(database=database) + + +class User(saffier.Model): + id = saffier.BigIntegerField(primary_key=True) + name = saffier.CharField(max_length=255) + email = saffier.CharField(max_length=255) + is_verified = saffier.BooleanField(default=False) + + class Meta: + registry = registry diff --git a/docs_src/signals/receiver/multiple.py b/docs_src/signals/receiver/multiple.py new file mode 100644 index 0000000..0d1a06a --- /dev/null +++ b/docs_src/signals/receiver/multiple.py @@ -0,0 +1,21 @@ +import saffier + +database = saffier.Database("sqlite:///db.sqlite") +registry = saffier.Registry(database=database) + + +class User(saffier.Model): + id = saffier.BigIntegerField(primary_key=True) + name = saffier.CharField(max_length=255) + email = saffier.CharField(max_length=255) + + class Meta: + registry = registry + + +class Profile(saffier.Model): + id = saffier.BigIntegerField(primary_key=True) + profile_type = saffier.CharField(max_length=255) + + class Meta: + registry = registry diff --git a/docs_src/signals/receiver/multiple_receivers.py b/docs_src/signals/receiver/multiple_receivers.py new file mode 100644 index 0000000..d0a390c --- /dev/null +++ b/docs_src/signals/receiver/multiple_receivers.py @@ -0,0 +1,27 @@ +from saffier.core.signals import post_save + + +def push_notification(email: str) -> None: + # Sends a push notification + ... + + +def send_email(email: str) -> None: + # Sends an email + ... + + +@post_save(User) +async def after_creation(sender, instance, **kwargs): + """ + Sends a notification to the user + """ + send_email(instance.email) + + +@post_save(User) +async def do_something_else(sender, instance, **kwargs): + """ + Sends a notification to the user + """ + push_notification(instance.email) diff --git a/docs_src/signals/receiver/post_multiple.py b/docs_src/signals/receiver/post_multiple.py new file mode 100644 index 0000000..e4d19da --- /dev/null +++ b/docs_src/signals/receiver/post_multiple.py @@ -0,0 +1,20 @@ +from saffier.core.signals import post_save + + +def send_notification(email: str) -> None: + """ + Sends a notification to the user + """ + send_email_confirmation(email) + + +@post_save([User, Profile]) +async def after_creation(sender, instance, **kwargs): + """ + Sends a notification to the user + """ + if isinstance(instance, User): + send_notification(instance.email) + else: + # something else for Profile + ... diff --git a/docs_src/signals/receiver/post_save.py b/docs_src/signals/receiver/post_save.py new file mode 100644 index 0000000..e257a96 --- /dev/null +++ b/docs_src/signals/receiver/post_save.py @@ -0,0 +1,16 @@ +from saffier.core.signals import post_save + + +def send_notification(email: str) -> None: + """ + Sends a notification to the user + """ + send_email_confirmation(email) + + +@post_save(User) +async def after_creation(sender, instance, **kwargs): + """ + Sends a notification to the user + """ + send_notification(instance.email) diff --git a/docs_src/signals/register.py b/docs_src/signals/register.py new file mode 100644 index 0000000..8e46bbc --- /dev/null +++ b/docs_src/signals/register.py @@ -0,0 +1,30 @@ +import saffier + +database = saffier.Database("sqlite:///db.sqlite") +registry = saffier.Registry(database=database) + + +class User(saffier.Model): + id = saffier.BigIntegerField(primary_key=True) + name = saffier.CharField(max_length=255) + email = saffier.CharField(max_length=255) + + class Meta: + registry = registry + + +# Create the custom signal +User.meta.signals.on_verify = saffier.Signal() + + +# Create the receiver +async def trigger_notifications(sender, instance, **kwargs): + """ + Sends email and push notification + """ + send_email(instance.email) + send_push_notification(instance.email) + + +# Register the receiver into the new Signal. +User.meta.signals.on_verify.connect(trigger_notifications) diff --git a/mkdocs.yml b/mkdocs.yml index faa0460..b2a5877 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,6 +48,7 @@ nav: - Related Name: "queries/related-name.md" - ManyToMany: "queries/many-to-many.md" - Prefetch Related: "queries/prefetch.md" + - Signals: "signals.md" - Transactions: "transactions.md" - Relationships: "relationships.md" - Connection: "connection.md" diff --git a/saffier/__init__.py b/saffier/__init__.py index cb56a70..6a5c564 100644 --- a/saffier/__init__.py +++ b/saffier/__init__.py @@ -37,6 +37,7 @@ from .core.db.querysets.base import QuerySet from .core.db.querysets.prefetch import Prefetch from .core.extras import SaffierExtra +from .core.signals import Signal from .exceptions import MultipleObjectsReturned, ObjectNotFound __all__ = [ @@ -74,6 +75,7 @@ "SaffierExtra", "SaffierSettings", "SET_NULL", + "Signal", "TextField", "TimeField", "UniqueConstraint", diff --git a/saffier/core/db/models/base.py b/saffier/core/db/models/base.py index 1275f90..6b79cd1 100644 --- a/saffier/core/db/models/base.py +++ b/saffier/core/db/models/base.py @@ -1,5 +1,5 @@ import functools -from typing import Any, ClassVar, Dict, Optional, Sequence, cast +from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional, Sequence, cast import sqlalchemy from sqlalchemy.engine import Engine @@ -13,6 +13,9 @@ from saffier.core.utils.model import DateParser from saffier.exceptions import ImproperlyConfigured +if TYPE_CHECKING: + from saffier.core.signals import Broadcaster + class SaffierBaseModel(DateParser, metaclass=BaseModelMeta): """ @@ -57,6 +60,16 @@ def table(self) -> sqlalchemy.Table: def table(self, value: sqlalchemy.Table) -> None: self._table = value + @functools.cached_property + def signals(self) -> "Broadcaster": + return self.__class__.signals # type: ignore + + def get_instance_name(self) -> str: + """ + Returns the name of the class in lowercase. + """ + return self.__class__.__name__.lower() + @classmethod def build(cls, schema: Optional[str] = None) -> sqlalchemy.Table: """ diff --git a/saffier/core/db/models/metaclasses.py b/saffier/core/db/models/metaclasses.py index bcf9dd1..620c3d7 100644 --- a/saffier/core/db/models/metaclasses.py +++ b/saffier/core/db/models/metaclasses.py @@ -24,6 +24,7 @@ from saffier.core.db.models.managers import Manager from saffier.core.db.relationships.related import RelatedField from saffier.core.db.relationships.relation import Relation +from saffier.core.signals import Broadcaster, Signal from saffier.exceptions import ForeignKeyBadConfigured, ImproperlyConfigured if TYPE_CHECKING: @@ -54,6 +55,7 @@ class MetaInfo: "related_names", "related_fields", "related_names_mapping", + "signals", ) def __init__(self, meta: Any = None, **kwargs: Any) -> None: @@ -79,6 +81,7 @@ def __init__(self, meta: Any = None, **kwargs: Any) -> None: self.related_names: Set[str] = set() self.related_fields: Dict[str, Any] = {} self.related_names_mapping: Dict[str, Any] = {} + self.signals: Optional[Broadcaster] = {} # type: ignore def _check_model_inherited_registry(bases: Tuple[Type, ...]) -> Type[Registry]: @@ -173,6 +176,20 @@ def _set_many_to_many_relation( setattr(model_class, settings.many_to_many_relation.format(key=field), relation) +def _register_model_signals(model_class: Type["Model"]) -> None: + """ + Registers the signals in the model's Broadcaster and sets the defaults. + """ + signals = Broadcaster() + signals.pre_save = Signal() + signals.pre_update = Signal() + signals.pre_delete = Signal() + signals.post_save = Signal() + signals.post_update = Signal() + signals.post_delete = Signal() + model_class.meta.signals = signals + + class BaseModelMeta(type): __slots__ = () @@ -381,6 +398,9 @@ def __search_for_fields(base: Type, attrs: Any) -> None: if isinstance(value, Manager): value.model_class = new_class + # Register the signals + _register_model_signals(new_class) + return new_class def get_db_shema(cls) -> Union[str, None]: @@ -424,6 +444,13 @@ def table_schema(cls, schema: str) -> Any: """ return cls.build(schema=schema) + @property + def signals(cls) -> "Broadcaster": + """ + Returns the signals of a class + """ + return cast("Broadcaster", cls.meta.signals) + @property def columns(cls) -> sqlalchemy.sql.ColumnCollection: return cast("sqlalchemy.sql.ColumnCollection", cls._table.columns) diff --git a/saffier/core/db/models/model.py b/saffier/core/db/models/model.py index 4203586..c4eb006 100644 --- a/saffier/core/db/models/model.py +++ b/saffier/core/db/models/model.py @@ -65,7 +65,7 @@ def model_dump( """ Dumps the model in a dict format. """ - row_dict = dict(self.__dict__.items()) + row_dict = {k: v for k, v in self.__dict__.items() if k in self.fields} if include is not None: row_dict = {k: v for k, v in row_dict.items() if k in include} @@ -79,23 +79,31 @@ async def update(self, **kwargs: typing.Any) -> typing.Any: """ Update operation of the database fields. """ + await self.signals.pre_update.send(sender=self.__class__, instance=self) + fields = {key: field.validator for key, field in self.fields.items() if key in kwargs} validator = Schema(fields=fields) kwargs = self.update_auto_now_fields(validator.check(kwargs), self.fields) pk_column = getattr(self.table.c, self.pkname) expression = self.table.update().values(**kwargs).where(pk_column == self.pk) await self.database.execute(expression) + await self.signals.post_update.send(sender=self.__class__, instance=self) # Update the model instance. for key, value in kwargs.items(): setattr(self, key, value) + return self + async def delete(self) -> None: """Delete operation from the database""" + await self.signals.pre_delete.send(sender=self.__class__, instance=self) + pk_column = getattr(self.table.c, self.pkname) expression = self.table.delete().where(pk_column == self.pk) await self.database.execute(expression) + await self.signals.post_delete.send(sender=self.__class__, instance=self) async def load(self) -> None: # Build the select expression. @@ -140,6 +148,8 @@ async def save( When creating a user it will make sure it can update existing or create a new one. """ + await self.signals.pre_save.send(sender=self.__class__, instance=self) + extracted_fields = self.extract_db_fields() if getattr(self, "pk", None) is None and self.fields[self.pkname].autoincrement: @@ -160,7 +170,9 @@ async def save( if getattr(self, "pk", None) is None or force_save: await self._save(**kwargs) else: + await self.signals.pre_update.send(sender=self.__class__, instance=self, kwargs=kwargs) await self._update(**kwargs) + await self.signals.post_update.send(sender=self.__class__, instance=self) # Refresh the results if any( @@ -169,6 +181,8 @@ async def save( if name not in extracted_fields ): await self.load() + + await self.signals.post_save.send(sender=self.__class__, instance=self) return self diff --git a/saffier/core/db/querysets/base.py b/saffier/core/db/querysets/base.py index 37ba5a0..d21e535 100644 --- a/saffier/core/db/querysets/base.py +++ b/saffier/core/db/querysets/base.py @@ -793,6 +793,8 @@ async def bulk_update(self, objs: List[SaffierModel], fields: List[str]) -> None async def delete(self) -> None: queryset: "QuerySet" = self.clone() + await self.model_class.signals.pre_delete.send(sender=self.__class__, instance=self) + expression = queryset.table.delete() for filter_clause in queryset.filter_clauses: expression = expression.where(filter_clause) @@ -800,6 +802,8 @@ async def delete(self) -> None: queryset.set_query_expression(expression) await queryset.database.execute(expression) + await self.model_class.signals.post_delete.send(sender=self.__class__, instance=self) + async def update(self, **kwargs: Any) -> None: """ Updates a record in a specific table with the given kwargs. @@ -815,6 +819,11 @@ async def update(self, **kwargs: Any) -> None: kwargs = queryset.update_auto_now_fields( validator.check(kwargs), queryset.model_class.fields ) + + await self.model_class.signals.pre_update.send( + sender=self.__class__, instance=self, kwargs=kwargs + ) + expression = queryset.table.update().values(**kwargs) for filter_clause in queryset.filter_clauses: @@ -823,6 +832,8 @@ async def update(self, **kwargs: Any) -> None: queryset.set_query_expression(expression) await queryset.database.execute(expression) + await self.model_class.signals.post_update.send(sender=self.__class__, instance=self) + async def get_or_create( self, defaults: Dict[str, Any], **kwargs: Any ) -> Tuple[SaffierModel, bool]: diff --git a/saffier/core/signals/__init__.py b/saffier/core/signals/__init__.py new file mode 100644 index 0000000..3c07021 --- /dev/null +++ b/saffier/core/signals/__init__.py @@ -0,0 +1,13 @@ +from .handlers import post_delete, post_save, post_update, pre_delete, pre_save, pre_update +from .signal import Broadcaster, Signal + +__all__ = [ + "Broadcaster", + "Signal", + "post_delete", + "post_save", + "post_update", + "pre_delete", + "pre_save", + "pre_update", +] diff --git a/saffier/core/signals/handlers.py b/saffier/core/signals/handlers.py new file mode 100644 index 0000000..28dcb09 --- /dev/null +++ b/saffier/core/signals/handlers.py @@ -0,0 +1,67 @@ +from typing import TYPE_CHECKING, Callable, List, Type, Union + +if TYPE_CHECKING: # pragma: no cover + from saffier import Model + + +class Send: + """ + Base for all the wrappers handling the signals. + """ + + def consumer(signal: str, senders: Union[Type["Model"], List[Type["Model"]]]) -> Callable: + """ + Connects the function to all the senders. + """ + + def wrapper(func: Callable) -> Callable: + _senders = [senders] if not isinstance(senders, list) else senders + + for sender in _senders: + signals = getattr(sender.meta.signals, signal) + signals.connect(func) + return func + + return wrapper + + +def pre_save(senders: Union[Type["Model"], List[Type["Model"]]]) -> Callable: + """ + Connects all the senders to pre_save. + """ + return Send.consumer(signal="pre_save", senders=senders) + + +def pre_update(senders: Union[Type["Model"], List[Type["Model"]]]) -> Callable: + """ + Connects all the senders to pre_update. + """ + return Send.consumer(signal="pre_update", senders=senders) + + +def pre_delete(senders: Union[Type["Model"], List[Type["Model"]]]) -> Callable: + """ + Connects all the senders to pre_delete. + """ + return Send.consumer(signal="pre_delete", senders=senders) + + +def post_save(senders: Union[Type["Model"], List[Type["Model"]]]) -> Callable: + """ + Connects all the senders to post_save. + """ + return Send.consumer(signal="post_save", senders=senders) + + +def post_update(senders: Union[Type["Model"], List[Type["Model"]]]) -> Callable: + """ + Connects all the senders to post_update. + """ + return Send.consumer(signal="post_update", senders=senders) + + +def post_delete(senders: Union[Type["Model"], List[Type["Model"]]]) -> Callable: + """ + Connects all the senders to post_delete. + """ + return Send.consumer(signal="post_delete", senders=senders) diff --git a/saffier/core/signals/signal.py b/saffier/core/signals/signal.py new file mode 100644 index 0000000..2bea23a --- /dev/null +++ b/saffier/core/signals/signal.py @@ -0,0 +1,68 @@ +import asyncio +from typing import TYPE_CHECKING, Any, Callable, Dict, Tuple, Type, Union + +from saffier.exceptions import SignalError +from saffier.utils.inspect import func_accepts_kwargs + +if TYPE_CHECKING: + from saffier import Model + + +def make_id(target: Any) -> Union[int, Tuple[int, int]]: + """ + Creates an id for a function. + """ + if hasattr(target, "__func__"): + return (id(target.__self__), id(target.__func__)) + return id(target) + + +class Signal: + """ + Base class for all Saffier signals. + """ + + def __init__(self) -> None: + """ + Creates a new signal. + """ + self.receivers: Dict[Union[int, Tuple[int, int]], Callable] = {} + + def connect(self, receiver: Callable) -> None: + """ + Connects a given receiver to the the signal. + """ + if not callable(receiver): + raise SignalError("The signals should be callables") + + if not func_accepts_kwargs(receiver): + raise SignalError("Signal receivers must accept keyword arguments (**kwargs).") + + key = make_id(receiver) + if key not in self.receivers: + self.receivers[key] = receiver + + def disconnect(self, receiver: Callable) -> bool: + """ + Removes the receiver from the signal. + """ + key = make_id(receiver) + func: Union[Callable, None] = self.receivers.pop(key, None) + return True if func is not None else False + + async def send(self, sender: Type["Model"], **kwargs: Any) -> None: + """ + Sends the notification to all the receivers. + """ + receivers = [func(sender=sender, **kwargs) for func in self.receivers.values()] + await asyncio.gather(*receivers) + + +class Broadcaster(dict): + def __getattr__(self, item: str) -> Signal: + return self.setdefault(item, Signal()) # type: ignore + + def __setattr__(self, __name: str, __value: Signal) -> None: + if not isinstance(__value, Signal): + raise SignalError(f"{__value} is not valid signal") + self[__name] = __value diff --git a/saffier/exceptions.py b/saffier/exceptions.py index c56e554..69ac618 100644 --- a/saffier/exceptions.py +++ b/saffier/exceptions.py @@ -68,5 +68,9 @@ class SchemaError(SaffierException): ... +class SignalError(SaffierException): + ... + + class CommandEnvironmentError(SaffierException): ... diff --git a/saffier/utils/__init__.py b/saffier/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/saffier/utils/inspect.py b/saffier/utils/inspect.py new file mode 100644 index 0000000..90a0753 --- /dev/null +++ b/saffier/utils/inspect.py @@ -0,0 +1,13 @@ +import inspect +from typing import Callable + + +def func_accepts_kwargs(func: Callable) -> bool: + """ + Checks if a function accepts **kwargs. + """ + return any( + param + for param in inspect.signature(func).parameters.values() + if param.kind == param.VAR_KEYWORD + ) diff --git a/tests/signals/test_signals.py b/tests/signals/test_signals.py new file mode 100644 index 0000000..f2d0512 --- /dev/null +++ b/tests/signals/test_signals.py @@ -0,0 +1,209 @@ +import pytest +from loguru import logger + +import saffier +from saffier.core.signals import ( + Broadcaster, + post_delete, + post_save, + post_update, + pre_delete, + pre_save, + pre_update, +) +from saffier.exceptions import SignalError +from saffier.testclient import DatabaseTestClient as Database +from tests.settings import DATABASE_URL + +database = Database(url=DATABASE_URL) +models = saffier.Registry(database=database) + +pytestmark = pytest.mark.anyio + + +class User(saffier.Model): + name = saffier.CharField(max_length=100) + language = saffier.CharField(max_length=200, null=True) + + class Meta: + registry = models + + +class Profile(saffier.Model): + name = saffier.CharField(max_length=100) + + class Meta: + registry = models + + +class Log(saffier.Model): + signal = saffier.CharField(max_length=255) + instance = saffier.JSONField() + + class Meta: + registry = models + + +@pytest.fixture(autouse=True, scope="function") +async def create_test_database(): + await models.create_all() + yield + await models.drop_all() + + +@pytest.fixture(autouse=True) +async def rollback_connections(): + with database.force_rollback(): + async with database: + yield + + +@pytest.mark.parametrize("func", ["bad", 1, 3, [3], {"name": "test"}]) +def test_passing_not_callable(func): + with pytest.raises(SignalError): + pre_save(User)(func) + + +def test_passing_no_kwargs(): + with pytest.raises(SignalError): + + @pre_save(User) + def execute(sender, instance): + ... + + +def test_invalid_signal(): + broadcaster = Broadcaster() + with pytest.raises(SignalError): + broadcaster.save = 1 + + +async def test_signals(): + @pre_save(User) + async def pre_saving(sender, instance, **kwargs): + await Log.query.create(signal="pre_save", instance=instance.model_dump()) + logger.info(f"pre_save signal broadcasted for {instance.get_instance_name()}") + + @post_save(User) + async def post_saving(sender, instance, **kwargs): + await Log.query.create(signal="post_save", instance=instance.model_dump()) + logger.info(f"post_save signal broadcasted for {instance.get_instance_name()}") + + @pre_update(User) + async def pre_updating(sender, instance, **kwargs): + await Log.query.create(signal="pre_update", instance=instance.model_dump()) + logger.info(f"pre_update signal broadcasted for {instance.get_instance_name()}") + + @post_update(User) + async def post_updating(sender, instance, **kwargs): + await Log.query.create(signal="post_update", instance=instance.model_dump()) + logger.info(f"post_update signal broadcasted for {instance.get_instance_name()}") + + @pre_delete(User) + async def pre_deleting(sender, instance, **kwargs): + await Log.query.create(signal="pre_delete", instance=instance.model_dump()) + logger.info(f"pre_delete signal broadcasted for {instance.get_instance_name()}") + + @post_delete(User) + async def post_deleting(sender, instance, **kwargs): + await Log.query.create(signal="post_delete", instance=instance.model_dump()) + logger.info(f"post_delete signal broadcasted for {instance.get_instance_name()}") + + # Signals for the create + user = await User.query.create(name="Edgy") + logs = await Log.query.all() + + assert len(logs) == 2 + assert logs[0].signal == "pre_save" + assert logs[0].instance["name"] == user.name + assert logs[1].signal == "post_save" + + user = await User.query.create(name="Saffier") + logs = await Log.query.all() + + assert len(logs) == 4 + assert logs[2].signal == "pre_save" + assert logs[2].instance["name"] == user.name + assert logs[3].signal == "post_save" + + # For the updates + user = await user.update(name="Another Saffier") + logs = await Log.query.filter(signal__icontains="update").all() + + assert len(logs) == 2 + assert logs[0].signal == "pre_update" + assert logs[0].instance["name"] == "Saffier" + assert logs[1].signal == "post_update" + + user.signals.pre_update.disconnect(pre_updating) + user.signals.post_update.disconnect(post_updating) + + # Disconnect the signals + user = await user.update(name="Saffier") + logs = await Log.query.filter(signal__icontains="update").all() + assert len(logs) == 2 + + # Delete + await user.delete() + logs = await Log.query.filter(signal__icontains="delete").all() + assert len(logs) == 2 + + user.signals.pre_delete.disconnect(pre_deleting) + user.signals.post_delete.disconnect(post_deleting) + user.signals.pre_save.disconnect(pre_saving) + user.signals.post_save.disconnect(post_saving) + + users = await User.query.all() + assert len(users) == 1 + + +async def test_staticmethod_signals(): + class Static: + @staticmethod + @pre_save(User) + async def pre_save_one(sender, instance, **kwargs): + await Log.query.create(signal="pre_save_one", instance=instance.model_dump()) + + @staticmethod + @pre_save(User) + async def pre_save_two(sender, instance, **kwargs): + await Log.query.create(signal="pre_save_two", instance=instance.model_dump()) + + # Signals for the create + user = await User.query.create(name="Edgy") + logs = await Log.query.all() + + assert len(logs) == 2 + + user.signals.pre_save.disconnect(Static.pre_save_one) + user.signals.pre_save.disconnect(Static.pre_save_two) + + +async def test_multiple_senders(): + @pre_save([User, Profile]) + async def pre_saving(sender, instance, **kwargs): + await Log.query.create(signal="pre_save", instance=instance.model_dump()) + + user = await User.query.create(name="Edgy") + profile = await User.query.create(name="Profile Edgy") + + logs = await Log.query.all() + assert len(logs) == 2 + + user.signals.pre_save.disconnect(pre_saving) + profile.signals.pre_save.disconnect(pre_saving) + + +async def test_custom_signal(): + async def processing(sender, instance, **kwargs): + instance.name = f"{instance.name} ORM" + await instance.save() + + User.meta.signals.custom.connect(processing) + + user = await User.query.create(name="Edgy") + await User.meta.signals.custom.send(sender=User, instance=user) + + assert user.name == "Edgy ORM" + + User.meta.signals.custom.disconnect(processing) From d07a500b986ab80f1e03651c31617bd168535c89 Mon Sep 17 00:00:00 2001 From: tarsil Date: Wed, 30 Aug 2023 13:08:44 +0100 Subject: [PATCH 2/2] Release 0.18.0 --- docs/release-notes.md | 9 +++++++++ saffier/__init__.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/release-notes.md b/docs/release-notes.md index acfe546..e6ce1fc 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,5 +1,14 @@ # Release Notes +### Added + +- New [Prefetch](./queries/prefetch.md) support allowing to simultaneously load nested data onto models. +- New [Signal](./signals.md) support allowing to "listen" to model events upon actions being triggered. + +### Changed + +- Updated pydantic and alembic + ## 0.17.1 ### Fixed diff --git a/saffier/__init__.py b/saffier/__init__.py index 6a5c564..6481834 100644 --- a/saffier/__init__.py +++ b/saffier/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.17.1" +__version__ = "0.18.0" from saffier.conf import settings from saffier.conf.global_settings import SaffierSettings