Signals
1. Basics
Django's signal dispatcher allows senders to notify receivers (or handlers) that an action has occurred. This is helpful for decoupling code and when you have different pieces of code that are interested in the same event. Signals are like event-based programming. You can hook up callback functions that get executed when specific events happen.
Some of the most common built-in signals in Django are:
django.db.models.signals.pre_save
anddjango.db.models.signals.post_save
- These signals are triggered before or after a model instancesave
method is called.django.db.models.signals.pre_delete
anddjango.db.models.signals.post_delete
- These signals are triggered before or after a model instance'sdelete
method is called and before or after a queryset'sdelete
method is called.django.core.signals.request_started
anddjango.core.signals.request_finished
- These signals are triggered when Django starts or finishes an HTTP request.django.contrib.auth.signals.user_logged_in
anddjango.contrib.auth.signals.user_logged_out
- These signals are triggered after a user successfully logs in or out of the application.
The most basic use of signals is when you connect your callable with a signal to be called when that signal is triggered.
For example, lets say we want to log every request that comes into out applications. We could create a function that would do that and connect it with the request_started
signal. Assume that we have an application called todos
:
# todos/tasks.py
def log_request(sender, **kwargs):
print('New Request!')
We then need to connect the function with the signal we want. This can be done in the default configuration class's ready
method of the todos
application's app.py
file.
# todos/app.py
from django.apps import AppConfig
from django.core.signals import request_started
from .tasks import log_request
class TodosConfig(AppConfig):
name = 'todos'
def ready(self):
request_started.connect(log_request)
That is all. Now every time a request comes into the application, the message "New Request!" will be logged in stdout.
The request_started
signals sends in the kwargs
dict only one argument, environ
, we could enhance our signal handler function to log something a little more helpful.
# todos/tasks.py
def log_request(sender, environ, **kwargs):
method = environ['REQUEST_METHOD']
host = environ['HTTP_HOST']
path = environ['PATH_INFO']
query = environ['QUERY_STRING']
query = '?' + query if query else ''
print('New Request -> {method} {host}{path}{query}'.format(
method=method,
host=host,
path=path,
query=query,
))
Now a more helpful message will be logged. The new message will look something like this: New Request -> GET 127.0.0.1:8000/
. Note also that it is common/good practice to identify the arguments for the signal you are writing the handler for that will be used. In our code above we do this by adding environ
specifically to the arguments list.
While the request_started
signal handler we created might not be very useful there are more useful signals that can be used. The django.db.models.signals.post_save
signal if useful for hooking in things that need to be done when a specific model is saved.
Assume, for example, that our todos
application has a user profile model (UserProfile
) that it wants to make sure is created for every user created in the system. One way to do this would be to connect a handler to the post_save
signal for the User
model in django.contrib.auth
.
# todos/tasks.py
from .models import UserProfile
...
def save_or_create_user_profile(sender, instance, created, **kwargs):
if created:
UserProfile.objects.create(user=instance)
else:
instance.user_profile.save()
Then in todos/app.py
we connect the signal handler to the signal.
# todos/app.py
...
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from .tasks import log_request, save_or_create_user_profile
class TodosConfig(AppConfig):
...
def ready(self):
...
post_save.connect(save_or_create_user_profile, sender=User)
Note that this time we pass the sender
argument when we connect our function to the post_save
signal. This means that our save_or_create_user_profile
function will only be called if the sender of the post_save
signal is the User
model. This also means that we can hookup to the post_save
signal with many different models executing different code to do different things.
Finally, it is important to understand that signals are synchronous or blocking. This means that all signal handlers will have to finish executing before the code can continue after a signal has been triggered. If you have 10 signal handlers connected to the request_started
signal, all 10 handlers would need to finish before Django even got to executing the middleware portion of the request. Keep this in mind when using signals and try not to use too many or do too much in them.
Creating and triggering custom signals
Django also allows you to create and trigger your own signals. This can be especially useful when creating a reusable package and you want developers down the road to be able to hook in bit of functionality to your existing code in a proven way. It can also be useful within your larger Django project. Lets look at an example of this.
# todo/tasks.py
import django.dispatch
todo_complete = django.dispatch.Signal(providing_args=['todo'])
In order to create a new signal it is as simple as instantiating a new Signal
instance and telling it what additional arguments you will be providing handlers.
Then to send a signal:
# todo/models.py
...
from .signals import todo_complete
...
class Todo(models.Model):
description = models.CharField(max_length=255)
done = models.BooleanField(default=False)
def mark_complete(self):
if not self.done:
self.done = True
self.save()
todo_complete.send(sender=self.__class__, todo=self)
Note the call to todo_complete.send
. This is calling the send
method of the custom signal instance. This setup allows other developers, or yourself in other areas of your code, to hook into the event (signal) of completing a todo item and attach any functionality they would like to that event.
2. Deep Dive: Code Walk-through
django.core.signals
Django's signals implementation starts in the django.core.signals
module (code). The first thing you may notice is that that module is very small (6 lines of code). All of the signals implementation is in the django.dispatch
package (code).
What is in the signals module are the core signals that Django provides: request_started
, request_finished
, got_request_exception
, setting_changed
. These provide good examples of what you need to do in order to create your own signals.
# django/core/signals.py
from django.dispatch import Signal
request_started = Signal(providing_args=["environ"])
...
Note that the request_started
signal that Django provides is simply an instance of the Signal
class provided by django.dispatch
.
django.dispatch.dispatcher.Signal
The Signal
class (implementation) is found in the django.dispatch.dispatcher
module (but can be imported from django.dispatch
).
We are going to take a closer look at three methods found in the Signal
class: connect
, send
, and send_robust
.
The connect
method is called by developer code when they want to connect a callable to the signal. The connect
method takes only one required arguments, the handler that should be called when the signals send
method is called.
Note that signal handlers are referred to as receivers
in the code.
# django/dispatch/dispatcher.py
...
class Signal:
...
def connect(self, receiver, sender=None, weak=True, dispatch_uid=None):
...
if dispatch_uid:
lookup_key = (dispatch_uid, _make_id(sender))
else:
lookup_key = (_make_id(receiver), _make_id(sender))
...
with self.lock:
...
for r_key, _ in self.receivers:
if r_key == lookup_key:
break
else:
self.receivers.append((lookup_key, receiver))
...
...
...
All this method really accomplishes is that is adds the receiver function to the list of receivers held by the signal (instance of Signal)
The connect
method above has been simplified from the actual implementation to help us understand what connect
is doing. First a lookup_key
is generated for the receiver. This allows the signal to identify the individual receivers and make sure that the same receiver isn't connected more than once to the signal.
Then self.lock
is used in a context manager to lock the signal for adding the receiver to make sure the self.receiver
list is only modified by one process at a time.
Inside the lock context manager block, the list of receivers is checked to make sure we don't add a duplicate (based on the lookup_key
) and finally added if there isn't one already.
So now our receiver had been added to the signals receiver
list. This list is then used by the send
method.
# django/dispatch/dispatcher.py
...
class Signal:
...
def send(self, sender, **named):
...
return [
(receiver, receiver(signal=self, sender=sender, **named))
for receiver in self._live_receivers(sender)
]
...
The send
method returns a list of tuples where the first element is the receiver (function) and the second element is the response from calling the receiver. Signal
has an internal method, _live_receivers
, that returns a list of active receivers for the specified sender
.
The implementation does this with a simple list comprehension.
One issue with calling the send
method to trigger the signal is that if one of the receivers raises an exception, there is no guarantee that all of the other receivers would be executed. This is where the send_robust
method can help.
The send_robust
method does the same thing as the send
method except that exceptions are caught and added to the return value for later evaluation. This method guarantees that all receivers will be allowed to execute.
# django/dispatch/dispatcher.py
...
class Signal:
...
def send_robust(self, sender, **named):
...
responses = []
for receiver in self._live_receivers(sender):
try:
response = receiver(signal=self, sender=sender, **named)
except Exception as err:
responses.append((receiver, err))
else:
responses.append((receiver, response))
return responses
...
Notice here that where the send
method had a simple list comprehension, the send_robust
method has a for loop that uses a try-except to make sure that every receiver is called and added to the responses list along with either the response from calling the receiver or the resulting exception that was raised.
Internally Django only uses the send
method for sending signals. The send_robust
can be used for your own custom signals.
Where are request_started
and request_finished
called from?
We know where the request_started
and request_finished
signals are created (django.core.signals
) but where are those signals triggered (sent)?
Django sends the request_started
signal from the django.core.handlers.wsgi.WSGIHandler
__call__
method (code). On the line 2 of the __call__
method you can see the call to send
. The WSGIHandler
class is the main entry point for Django applications. After an instance is instantiated, the __call__
method gets called for each request. The request_started
signal is essentially the first thing to be called on each request.
Django sends the request_finished
signal from the django.http.response.HttpResponseBase
classes close
method (code). The close
method (code) is called by the WSGI server on the response. When that happens the request_finished
signal is sent.
# django/http/response.py
...
class HttpResponseBase:
...
def close(self):
...
signals.request_finished.send(sender=self._handler_class)
We are obviously leaving a lot of things out here, but the important part is that the very last thing close
does is send the request_finished
signal.
3. Deep Dive: Language Features
for-else block
You may have noticed some strange code in the examples above, a for loop with an associated else block (in the discussion about django.dispatch.dispatcher.Signal
). This is not a common construct, but it exists in Python and its meaning is a little tough for some to remember.
letters = ['a', 'b', 'c']
looking_for = 'a'
for letter in letters:
if letter == looking_for:
print('Found the letter!')
break
else:
print("Didn't find the letter.")
I think the above example illustrates its use nicely. The else block gets executed with the whole for loop completes and the break statement isn't reached.
Context manager
A lot of times in code you have to acquire a resource, do something with it, and then clean things up afterwards. This is the case when working with files (open, process, close), locks (lock, process, release), and many other resources. Because it is such a common flow (run common setup code, do something, run tear down code) Python added a statement that makes this a lot nicer.
# working with files
# the standard method
file = open('tmp.txt')
... # processing the file
file.close()
# context manager method
with open('tmp.txt') as file:
... # processing the file
The above code shows two methods of working with files. Both methods work just fine in Python. However the context manager method (using the with
statement) is safer. The with
statement in this context guarantees that the file will be closed, even if an exception is raised in the processing code.
We can see another use of context managers in the django.dispatch.dispatcher.Signal
classes connect
method. In there a lock is acquired and released using the with
statement. Once again, this has the benefit of being safer because the lock is guaranteed to be released even if an exception is raised in the with
block code.
4. Hands-on Exercises
Implement todo_done
and todo_undone
signals. Call the send method from the methods on the Todo
model such as mark_done
and mark_undone
. Hook into the signal a callback that logs the item that was completed or undone.
Hints
- We did something very similar to this in the Basics section above.
Possible Solution
# todo/signals.py
import django.dispatch
todo_done = django.dispatch.Signal(providing_args=['item'])
todo_undone = django.dispatch.Signal(providing_args=['item'])
# todo/models.py
...
from .signals import todo_done, todo_undone
...
class Todo(models.Model):
...
def mark_done(self):
if not self.done:
self.done = True
self.save()
todo_done.send(sender=self.__class__, item=self)
def mark_undone(self):
if self.done:
self.done = False
self.save()
todo_undone.send(sender=self.__class__, item=self)
...
# todo/tasks.py
...
def log_todo_action(sender, item, **kwargs):
if item.done:
logger.info(f'Item complete: {item.item}')
else:
logger.info(f'Item undone: {item.item}')
# todo/app.py
...
from . import signals, tasks
...
class TodoConfig(AppConfig):
...
def ready(self):
signals.todo_done.connect(tasks.log_todo_action)
signals.todo_undone.connect(tasks.log_todo_action)
Note: This is just one solution for solving this problem. The problem can be solved correctly in many different ways.
5. Contribute
Resources
- Django Signals documentation
- Signals open tickets (refer to Understanding Django's Ticketing System for more details)