Signals¶
In Edgy, signals provide a mechanism to "listen" to model events, triggering specific actions when events like saving or deleting occur. This is similar to Django's signals but also draws inspiration from Ormar's implementation, and leverages the blinker library for anonymous signals.
What are Signals?¶
Signals are used to execute custom logic when certain events happen within Edgy models. They enable decoupling of concerns, allowing you to perform actions like sending notifications, updating related data, or logging events without cluttering your model definitions.
Default Signals¶
Edgy provides default signals for common model lifecycle events, which you can use out of the box.
How to Use Them¶
The default signals are located in edgy.core.signals. Import them as follows:
from edgy.core.signals import (
pre_delete,
post_delete,
pre_save,
post_save,
pre_update,
post_update,
post_migrate,
pre_migrate,
pre_relation_add,
post_relation_add,
pre_relation_remove,
post_relation_remove,
pre_bulk,
post_bulk,
)
Pre Operations special exception¶
If you just want to skip the operation without causing a bigger error, you can raise edgy.exceptions.SkipOperation to stop the operation and
returning an empty value and send the corresponding post signal with operation_skipped=True.
pre_save¶
Triggered before a model is saved (during Model.save() and Model.query.create()).
pre_save(send: type["Model"], instance: Union["Model", "QuerySet"], model_instance: "Model", values: dict, column_values: dict, is_update: bool)
Only the EXPLICIT_SPECIFIED_VALUES contextvar is available.
post_save¶
Triggered after a model is saved (during Model.save() and Model.query.create()).
post_save(send: type["Model"], instance: Union["Model", "QuerySet"], model_instance: "Model", values: dict, column_values: dict, is_update: bool)
pre_update¶
Triggered before a model is updated (during Model.update() and Model.query.update()).
pre_update(sender: type["Model"], instance: Union["Model", "QuerySet"], model_instance: Optional["Model"], values: dict, column_values: dict)
post_update¶
Triggered after a model is updated (during Model.update() and Model.query.update()).
post_update(sender: type["Model"], instance: Union["Model", "QuerySet"], model_instance: Optional["Model"], values: dict, column_values: dict)
pre_save, post_save, pre_update, post_update parameters¶
The receiver function receives following parameters:
instance- The model or QuerySet instance.model_instance-The model instance if available. For save signals always availablevalues- The passed values.column_values- The parsed values which are used for the db.is_update- Is it an update? This is also set for*_updateto match the save parameters.is_migration- Called fromapply_default_force_nullable_fieldswhich is mostly for migrations. Here we have model instances.row_count- (post only) The rows updated.operation_skipped- (post only) If the operation was skipped.
pre_delete¶
Triggered before a model is deleted (during Model.delete() and Model.query.delete()). Exception: intermediate model instances when using relations.
pre_delete(send: type["Model"], instance: Union["Model", "QuerySet"], model_instance: Optional["Model"])
A more advanced example is:
import edgy
class BaseModel(edgy.StrictModel):
protected = edgy.BooleanField(default=False)
__deletion_with_signals__ = True
__require_model_based_deletion__ = True
class Meta:
registry = ...
abstract = True
class Friend(BaseModel):
name = edgy.CharField(max_length=100)
class Profile(BaseModel):
name = edgy.CharField(max_length=100)
class User(BaseModel):
name = edgy.CharField(max_length=100)
profile = edgy.ForeignKey("Profile", null=True, on_delete=edgy.CASCADE, related_name="users")
friends = edgy.ManyToMany("Friend", related_name="users")
@User.meta.signals.pre_relation_remove.connect_via(User)
@Friend.meta.signals.pre_relation_remove.connect_via(Friend.meta.fields["users"].through)
@Profile.meta.signals.pre_relation_remove.connect_via(Profile)
async def abort_removal_relation(sender, raw_values, **kwargs):
for value in raw_values:
if value.protected:
raise Exception()
@User.meta.signals.pre_delete.connect_via(User)
@Friend.meta.signals.pre_delete.connect_via(Friend.meta.fields["users"].through)
@Profile.meta.signals.pre_delete.connect_via(Profile)
async def abort_removal_relation(sender, model_instance, **kwargs):
if model_instance.protected:
raise Exception()
pre_delete parameters¶
instance- The model or QuerySet instance.model_instance- The model instance if available otherwiseNone.injected_filters(query only) - You can insert or remove (when inserted by another signal) extra filter parameters for query deletions.
Example for the insertion of new parameters
import edgy
from edgy.exceptions import SkipOperation
class BaseModel(edgy.StrictModel):
protected = edgy.BooleanField(default=False)
class Meta:
registry = ...
abstract = True
class Friend(BaseModel):
name = edgy.CharField(max_length=100)
class Profile(BaseModel):
name = edgy.CharField(max_length=100)
class User(BaseModel):
name = edgy.CharField(max_length=100)
profile = edgy.ForeignKey("Profile", null=True, on_delete=edgy.CASCADE, related_name="users")
friends = edgy.ManyToMany("Friend", related_name="users")
@User.meta.signals.pre_relation_remove.connect_via(User)
@Profile.meta.signals.pre_relation_remove.connect_via(Profile)
async def excempt_removal_relation(sender, raw_values, **kwargs):
new_raw_values = list(raw_values)
raw_values.clear()
for value in new_raw_values:
if not value.protected:
raw_values.append(value)
@User.meta.signals.pre_delete.connect_via(User)
@Profile.meta.signals.pre_delete.connect_via(Profile)
async def abort_removal_relation(sender, model_instance, injected_filters, **kwargs):
if model_instance is None:
injected_filters.append({"protected": True})
else:
if model_instance.protected:
raise SkipOperation()
You can also add or_, and_ or other clauses valid for the filter method of QuerySet.
By default they are combined like with and_.
post_delete¶
Triggered after a model is deleted (during Model.delete() and Model.query.delete()). Exception: through model instances when using relation methods.
post_delete(send: type["Model"], instance: Union["Model", "QuerySet"], model_instance: Optional["Model"], row_count: Optional[int])
post_delete parameters¶
- instance - The model or QuerySet instance.
- row_count - How many rows are deleted (only some dbs can be None).
- model_instance -The model instance if available.
pre_migrate¶
Triggered before upgrading, downgrading or creating a migration. This signal is sync but can be used with async receivers too.
It has following senders:
- "upgrade"
- "downgrade"
- "revision"
post_migrate¶
Triggered after upgrading, downgrading or creating a migration. This signal is sync but can be used with async receivers too.
It has following senders:
- "upgrade"
- "downgrade"
- "revision"
pre_migrate & post_migrate parameters¶
Basically all parameters which are passed to the alembic function
That are for upgrade/downgrade:
config- The configuration object.revision- The revision to use (relative or absolute).sql- Is offline mode (outputs sql).tag- Parameters for env.py script.
And for revision:
config- The configuration object.message- The message.autogenerate- Shall the migration file be autogenerated?sql- Offline mode.head- Head parameter of alembic.splice- Splice parameter of alembic.branch_labelversion_pathrevision_id- Revision id of the migration.
pre_bulk¶
The pre_bulk signal is issued before the database modifications and allows before executing bulk operations to manipulate the instances. Sender is the queryset. When using bulk operations on the relation queryset the sender is either the target model (one-to-many) or through model (many-to-many).
import edgy
class BaseModel(edgy.StrictModel):
active = edgy.IntegerField(default=0)
duplicate: bool = False
class Meta:
registry = ...
abstract = True
class Friend(BaseModel):
name = edgy.CharField(max_length=100)
class Profile(BaseModel):
name = edgy.CharField(max_length=100)
class User(BaseModel):
name = edgy.CharField(max_length=100)
profile = edgy.ForeignKey("Profile", null=True, on_delete=edgy.CASCADE, related_name="users")
friends = edgy.ManyToMany("Friend", related_name="users")
@User.meta.signals.pre_bulk.connect_via(User)
@Profile.meta.signals.pre_bulk.connect_via(Profile)
async def handle_active_duplicate_bulk(sender, raw_values, create_params, update_params, **kwargs):
for item in create_params:
if item[0].active > 0:
item[0].active = 0
for item in update_params:
if item[0].active > 0:
item[0].active -= 1
if item[0].duplicate:
create_params.append((item[0].model_copy(), item[1], item[2]))
post_bulk¶
The post_bulk signal is issued after the database modifications and contains information about how many rows were created and/or updated. When using bulk operations on the relation queryset the sender is either the target model (one-to-many) or through model (many-to-many).
Parameters of *_bulk signals¶
raw_values: Raw model instances with created flag. No resolving ofembed_parent.values(post only, only when operation_skipped=False): Resolved model instances with created flag. When not usingresolve_embed, the raw model instances.operation:bulk_create,bulk_update,bulk_update_or_create,bulk_get_or_create.resolve_embed: Value ofresolve_embed.create_params:(raw_instance, position in raw_values, set of input kwarg names)tuple. You can prevent an insert operation by removing an tuple. You can move an tuple to update_params if the instance should be updated instead.update_params:(raw_instance, position in raw_values, set of input kwarg names)tuple. You can prevent an update operation by removing an tuple. You can move an tuple to create_params if the instance should be created instead (not recommended!).ignore_conflicts(forbulk_create): Value ofignore_conflicts.update_fields(for updating bulk operations): The fields updated.row_count_update(for updating bulk operations): Amount of rows updated.Nonefor db systems not supporting it.row_count_create(for creating bulk operations): Amount of rows created.Nonefor db systems not supporting it.
create_params and update_params
Theoretical you can change the logic of what is inserted or updated but this is not recommended especially because you are restricted to the model type and by the update_fields which you are not able to change.
You can however change the identifying_db_fields and move an instance to the update list or just remove it completely from both lists so it is only shown.
Or for methods allowing None in instance slots, even replace the raw_values slot with None. Be careful with the last trick! You can break core assumptions, so we can have None instances where typings forbid this.
Methods where this trick can be applied are: bulk_create (with ignore_conflicts=True), bulk_update, bulk_update_or_create.
pre_relation_add¶
The pre_relation_add signal is issued before the database modifications and allows before executing changing the relations to manipulate the instances.
The sender is either the target model (one-to-many) or through model (many-to-many). Signals are also issued on overwrites of pre_relation_add in source.meta.signals, through.meta.signals and target.meta.signals. However if the default signal or a shared signal is used it is only issued per different signal object, so you can expect when listening to one of the signals, you get notified only once.
Note
This signal is not issued if add_many is called with empty arguments or save_related is called when staged are empty.
post_relation_add¶
The post_relation_add signal is issued after the database modifications and contains information about how many rows were changed.
The sender is either the target model (one-to-many) or through model (many-to-many). Signals are also issued on overwrites of pre_relation_add in source.meta.signals, through.meta.signals and target.meta.signals. However if the default signal or a shared signal is used it is only issued per different signal object, so you can expect when listening to one of the signals, you get notified only once.
Note
This signal is not issued if add_many is called with empty arguments or save_related is called when staged are empty.
Parameters of *_relation_add signals¶
instance: Source instance.row_count: How many rows were updated/created?Nonefor db systems not supporting it.row_count_create: How many rows were created?Nonefor db systems not supporting it.raw_values: Raw model instances with created flag of either the source model (one_to_many) or thethroughmodel (many_to_many). Useful in combination withcreate_paramsandupdate_paramsto tweak output. There is no resolving viaresolve_embedvalues(post only, only when add, add_many andoperation_skipped=False): The resolved counterpart instances with created flag.operation:save_relatedandadd(also issued foradd_manyandcreate).field: RelationField name onsourcetriggering this signal.source: Source model which contains the RelationField triggering the signals.target: Target model.relation: Relation type.one_to_many,many_to_many.create_params: See bulk signal parameter.update_params: See bulk signal parameter.operation_skipped: Is the operation skipped? This will lead to missing parameters (values)
Replacing raw_values/values
Here every operation allows None values instead of instances. So it is no problem to replace in raw_values (pre) or values (post) with None signalling a missing instance/failed update.
pre_relation_remove¶
The pre_relation_remove signal is issued before the database modifications for the removal of connections and allow customizations including to stop the deletion by issuing an exception.
The sender is either the target model (one-to-many) or through model (many-to-many). Signals are also issued on overwrites of pre_relation_add in source.meta.signals, through.meta.signals and target.meta.signals. However if the default signal or a shared signal is used it is only issued per different signal object, so you can expect when listening to one of the signals, you get notified only once.
Note
This signal is not issued if remove_many is called with empty arguments.
Remove from removal list
You have two options to block the removal of an instance
1. raise an exception
2. remove the instance from raw_values
import edgy
class BaseModel(edgy.StrictModel):
protected = edgy.BooleanField(default=False)
__deletion_with_signals__ = True
__require_model_based_deletion__ = True
class Meta:
registry = ...
abstract = True
class Friend(BaseModel):
name = edgy.CharField(max_length=100)
class Profile(BaseModel):
name = edgy.CharField(max_length=100)
class User(BaseModel):
name = edgy.CharField(max_length=100)
profile = edgy.ForeignKey("Profile", null=True, on_delete=edgy.CASCADE, related_name="users")
friends = edgy.ManyToMany("Friend", related_name="users")
@User.meta.signals.pre_relation_remove.connect_via(User)
@Friend.meta.signals.pre_relation_remove.connect_via(Friend.meta.fields["users"].through)
@Profile.meta.signals.pre_relation_remove.connect_via(Profile)
async def abort_removal_relation(sender, raw_values, **kwargs):
for value in raw_values:
if value.protected:
raise Exception()
@User.meta.signals.pre_delete.connect_via(User)
@Friend.meta.signals.pre_delete.connect_via(Friend.meta.fields["users"].through)
@Profile.meta.signals.pre_delete.connect_via(Profile)
async def abort_removal_relation(sender, model_instance, **kwargs):
if model_instance.protected:
raise Exception()
import edgy
from edgy.exceptions import SkipOperation
class BaseModel(edgy.StrictModel):
protected = edgy.BooleanField(default=False)
class Meta:
registry = ...
abstract = True
class Friend(BaseModel):
name = edgy.CharField(max_length=100)
class Profile(BaseModel):
name = edgy.CharField(max_length=100)
class User(BaseModel):
name = edgy.CharField(max_length=100)
profile = edgy.ForeignKey("Profile", null=True, on_delete=edgy.CASCADE, related_name="users")
friends = edgy.ManyToMany("Friend", related_name="users")
@User.meta.signals.pre_relation_remove.connect_via(User)
@Profile.meta.signals.pre_relation_remove.connect_via(Profile)
async def excempt_removal_relation(sender, raw_values, **kwargs):
new_raw_values = list(raw_values)
raw_values.clear()
for value in new_raw_values:
if not value.protected:
raw_values.append(value)
@User.meta.signals.pre_delete.connect_via(User)
@Profile.meta.signals.pre_delete.connect_via(Profile)
async def abort_removal_relation(sender, model_instance, injected_filters, **kwargs):
if model_instance is None:
injected_filters.append({"protected": True})
else:
if model_instance.protected:
raise SkipOperation()
post_relation_remove¶
The post_relation_remove signal is issued after the database modifications for the removal of connections and contains information about how many rows were changed/removed.
The sender is either the target model (one-to-many) or through model (many-to-many). Signals are also issued on overwrites of pre_relation_add in source.meta.signals, through.meta.signals and target.meta.signals. However if the default signal or a shared signal is used it is only issued per different signal object, so you can expect when listening to one of the signals, you get notified only once.
Note
This signal is not issued if remove_many is called with empty arguments.
Parameters of *_relation_remove signals¶
instance: Source instance.row_count(post): How many rows were updated/deleted?Nonefor db systems not supporting it.raw_values: Raw model instances without created flag of either the source model (one_to_many) or thethroughmodel (many_to_many). There is no resolving viaresolve_embed. You can clear the list and readd the models you want to delete (or doing position based modifications (harder)) as long you don't await during the modifications. You should recheck if something changes if you fetch something with await.field: RelationField name onsourcetriggering this signal.source: Source model which contains the RelationField triggering the signals.target: Target model.relation: Relation type.one_to_many,many_to_many.model_based_deletion(many_to_many): Was amodel_based_deletionused?
Receiver¶
A receiver is a function that executes when a signal is triggered. It "listens" for a specific event.
Example: Given the following model:
import edgy
database = edgy.Database("sqlite:///db.sqlite")
registry = edgy.Registry(database=database)
class User(edgy.Model):
id: int = edgy.BigIntegerField(primary_key=True)
name: str = edgy.CharField(max_length=255)
email: str = edgy.CharField(max_length=255)
is_verified: bool = edgy.BooleanField(default=False)
class Meta:
registry = registry
You can send an email to a user upon creation using the post_save signal:
from edgy.core.signals import post_save
def send_notification(email: str) -> None:
"""
Sends a notification to the user
"""
send_email_confirmation(email)
@post_save.connect_via(User)
async def after_creation(sender, instance, **kwargs):
"""
Sends a notification to the user
"""
send_notification(instance.email)
The @post_save decorator specifies the User model, indicating it listens for events on that model.
Requirements¶
Receivers must meet the following criteria:
- Must be a callable (function).
- Must have
senderas the first argument (the model class). - Must have
**kwargsto accommodate changes in model attributes. - Must be
asyncto match Edgy's async operations.
Multiple Receivers¶
You can use the same receiver for multiple models:
import edgy
database = edgy.Database("sqlite:///db.sqlite")
registry = edgy.Registry(database=database)
class User(edgy.Model):
id: int = edgy.BigIntegerField(primary_key=True)
name: str = edgy.CharField(max_length=255)
email: str = edgy.CharField(max_length=255)
class Meta:
registry = registry
class Profile(edgy.Model):
id: int = edgy.BigIntegerField(primary_key=True)
profile_type: str = edgy.CharField(max_length=255)
class Meta:
registry = registry
from edgy.core.signals import post_save
def send_notification(email: str) -> None:
"""
Sends a notification to the user
"""
send_email_confirmation(email)
@post_save.connect_via(User)
@post_save.connect_via(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
...
Multiple Receivers for the Same Model¶
You can have multiple receivers for the same model:
from edgy.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.connect_via(User)
async def after_creation(sender, instance, **kwargs):
"""
Sends a notification to the user
"""
send_email(instance.email)
@post_save.connect_via(User)
async def do_something_else(sender, instance, **kwargs):
"""
Sends a notification to the user
"""
push_notification(instance.email)
Disconnecting Receivers¶
You can disconnect a receiver to prevent it from running:
from edgy.core.signals import post_save
def send_notification(email: str) -> None:
"""
Sends a notification to the user
"""
send_email_confirmation(email)
@post_save.connect_via(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)
Custom Signals¶
Edgy allows you to define custom signals, extending beyond the default ones.
Continuing with the User model example:
import edgy
database = edgy.Database("sqlite:///db.sqlite")
registry = edgy.Registry(database=database)
class User(edgy.Model):
id: int = edgy.BigIntegerField(primary_key=True)
name: str = edgy.CharField(max_length=255)
email: str = edgy.CharField(max_length=255)
is_verified: bool = edgy.BooleanField(default=False)
class Meta:
registry = registry
Create a custom signal named on_verify:
import edgy
from edgy.core import signals
from edgy import Signal
# or:
# from blinker import Signal
database = edgy.Database("sqlite:///db.sqlite")
registry = edgy.Registry(database=database)
class User(edgy.Model):
id: int = edgy.BigIntegerField(primary_key=True)
name: str = edgy.CharField(max_length=255)
email: str = edgy.CharField(max_length=255)
class Meta:
registry = registry
# Create the custom signal
User.meta.signals.on_verify = Signal()
The on_verify signal is now available for the User model.
Danger
Signals are class-level attributes, affecting all derived instances. Use caution when creating custom signals.
Create a receiver for the custom signal:
import edgy
from edgy import Signal
# or:
# from blinker import Signal
database = edgy.Database("sqlite:///db.sqlite")
registry = edgy.Registry(database=database)
class User(edgy.Model):
id: int = edgy.BigIntegerField(primary_key=True)
name: str = edgy.CharField(max_length=255)
email: str = edgy.CharField(max_length=255)
class Meta:
registry = registry
# Create the custom signal
User.meta.signals.on_verify = 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)
The trigger_notifications receiver is now connected to the on_verify signal.
Rewire Signals¶
To prevent default lifecycle signals from being called, you can overwrite them per class or use the set_lifecycle_signals_from method of the Broadcaster:
import edgy
from edgy import Signal
# or:
# from blinker import Signal
database = edgy.Database("sqlite:///db.sqlite")
registry = edgy.Registry(database=database)
class User(edgy.Model):
id: int = edgy.BigIntegerField(primary_key=True)
name: str = edgy.CharField(max_length=255)
email: str = edgy.CharField(max_length=255)
class Meta:
registry = registry
# Overwrite a model lifecycle Signal; this way the main signals.pre_delete is not triggered
User.meta.signals.pre_delete = Signal()
# Update all lifecyle signals. Replace pre_delete again with the default
User.meta.signals.set_lifecycle_signals_from(signals)
How to Use It¶
Using a custom signal Use the custom signal in your logic:
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_async(User, instance=user)
# or when maybe a proxy
await User.meta.signals.on_verify.send_async(User.get_real_class(), instance=user)
on_verify signal is triggered only when the user is verified.
Log changes
An other useful usecase is logging user actions:
from sqlalchemy import ForeignKey
import edgy
from contextvars import ContextVar
from edgy.core import signals
models = edgy.Registry(...)
current_user = ContextVar("current_user", default=None)
class BaseModel(edgy.StrictModel):
class Meta:
registry = models
abstract = True
class Friend(BaseModel):
name = edgy.CharField(max_length=100)
class Profile(BaseModel):
name = edgy.CharField(max_length=100)
class User(BaseModel):
name = edgy.CharField(max_length=100)
profile = edgy.ForeignKey("Profile", null=True, on_delete=edgy.CASCADE, related_name="users")
friends = edgy.ManyToMany("Friend", related_name="users")
class Log(BaseModel):
signal = edgy.CharField(max_length=255)
class_name = edgy.CharField(max_length=255)
params = edgy.JSONField()
user = edgy.ForeignKey(User, null=True, on_delete=edgy.CASCADE, related_name="logs")
def __str__(self) -> str:
return str(self.extract_db_fields())
def __repr__(self) -> str:
return f"Log<{self}>"
for signal_name in dir(signals):
if not signal_name.startswith("post_") or signal_name == "post_migrate":
continue
signal: signals.Signal = getattr(signals, signal_name)
async def log(sender, _signal_name=signal_name, **kwargs):
await Log.query.create(
signal=_signal_name,
class_name=sender.__name__,
params={k: str(v) for k, v in kwargs.items()},
user=current_user.get(),
)
for model in models.models.values():
if model is not Log:
# weak must be False otherwise the receivers vanish
signal.connect(log, model, weak=False)
Of course there are better ways for serialization.
Disconnect the Signal¶
Disconnecting a custom signal is the same as disconnecting a default signal: