Signals

 

When (and when not) to use them

 

David Seddon

david@seddonym.me

http://seddonym.me

  1. Know what they are?
  2. Use them?
  3. Like them?
  4. Find them annoying?

Do you...

Django signals

  1. What are signals for?
  2. Under the hood
  3. Testing: tips and pitfalls

This talk

Part 1

What are signals for?
 

signal

receiver

receiver

receiver

receiver

send()

A signal sends messages to a set of connected receivers.

signal

receiver

send()

from django.dispatch import Signal

my_signal = Signal()

def my_receiver(sender, **kwargs):
    pass

connect


my_signal.connect(my_receiver)

my_signal.send(sender=None)

Receiver decorator

from django.dispatch import receiver

@receiver(signal)
def my_receiver(sender, **kwargs):
   pass
def my_receiver(sender, **kwargs):
   pass

my_signal.connect(my_receiver)

Instead of

use

Including receivers

You can define your receivers anywhere, but you need to make sure they get imported.

My preferred approach:

  • Define receivers in receivers.py in the relevant app.

  • In one of your apps, use module autodiscovery to automatically import receivers.py files.

# main/__init__.py

from django.apps import AppConfig as BaseAppConfig
from django.utils.module_loading import autodiscover_modules


class AppConfig(BaseAppConfig):
    name = 'main'

    def ready(self):
        # Automatically import all receivers files
        autodiscover_modules('receivers')
        

default_app_config = 'main.AppConfig'

Additional kwargs

@receiver(signal)
def my_receiver(sender, foo, **kwargs):
   pass
my_signal = Signal(providing_args=['foo'])
my_signal.send(sender=None, foo='bar')

Sender

@receiver(signal, sender=MyClass)
def my_receiver(sender, **kwargs):
    # This will only get called if MyClass is passed as the
    # sender when the signal is sent.
    pass
class MyClass:
    pass

my_signal.send(sender=None)
my_signal.send(sender='foo')
my_signal.send(sender=MyClass)

Use sender to connect your receivers only when the signal is sent from a specific sender

But why?

Django includes a “signal dispatcher” which helps allow decoupled applications get notified when actions occur elsewhere in the framework.

The Django docs

App

App

App

App

App

App

Django project with lots of circular dependencies (i.e. coupled applications)

Settings

App

App

App

App

App

App

Encapsulated Django project

(all the arrows go in one direction)

Settings

   App

   App

Signals allow apps higher up the dependency chain to react to things lower down.

receiver

signal

The only reason to use signals

 say_something('hello')
 def say_something(message):

Going with the flow:
no need for signals

Going against the flow:
use signals

   App

   App

Signals allow apps higher up the dependency chain to react to things lower down.

receiver

signal

The only reason to use signals

 say_something('hello')
 def say_something(message):

Going with the flow:
no need for signals

Going against the flow:
use signals

   App

   App

Signals allow apps higher up the dependency chain to react to things lower down.

receiver

signal

The only reason to use signals

 say_something('hello')
 def say_something(message):

Going with the flow:
no need for signals

Going against the flow:
use signals

The only reason for implementing a signal/receiver call is if it would break a dependency chain.

Built in signals

By far the most common use of signals is to react to Django's built in signals.

Text

from django.db.models.signals import post_save
from django.contrib.auth import User
from .utils import send_welcome_email


@receiver(post_save, sender=User)
def user_post_save_creation(sender, instance, created, **kwargs):
    if not created:
        return
    # Send a welcome email to new users
    send_welcome_email(user=instance)
https://docs.djangoproject.com/en/1.10/ref/signals/

Part 2

Under the hood
 

django/
  dispatch/
    __init__.py
    license.txt
    dispatcher.py
    weakref_backports.py

 

Imports dispatcher

PyDispatcher license

Main logic (316 lines)

Pre Python 3.4 Compatibility

Dispatcher framework is small

Dispatcher API

class Signal:
receivers
connect()
disconnect()
send()
receiver

Decorator that calls connect on the signal with the decorated function.

List of callbacks (well almost)

Adds supplied callback to the list

Removes supplied callback from the list

Calls each callback in receivers with the supplied arguments.

Weak references

Signals store receiver callbacks by weak references, which means that they won't be held in memory if they're deleted elsewhere.

If that's a problem, you can pass weak=False when connecting:

def connect_local_receivers():
    # Both of these inner functions will be garbage collected, by default.
    def local_receiver_weak(sender, **kwargs):
        print("Foo.")
    
    def local_receiver_strong(sender, **kwargs):
        print('Bar.')
    
    my_signal.connect(local_receiver_weak)
    my_signal.connect(local_receiver_strong, weak=False)

connect_local_receivers()
>>> my_signal.send(sender=None)
Bar.

Order of calls

Receivers are called in the order in which they were connected.

 

Depending on how you connected the receivers, this will probably be the same order as they are listed in INSTALLED_APPS.

Exception handling

By default, any exceptions will be unhandled by the dispatcher.

>>> my_signal.send(sender=None, instance=instance)
Exception: Something bad happened in a receiver connected to this.

If you want to process all of the receivers in turn, handling each exception, use send_robust:

>>> results = my_signal.send_robust(sender=None, instance=instance)
>>> any([isinstance(e, Exception) for receiver, e in results])
True

Part 3

Testing:
tips and pitfalls
 

Two things to test

  1. Connection - whether or not the receiver will be called.
  2. Behaviour - The internal behaviour of the receiver once called.

Testing gotchas

from unittest import TestCase
from unittest.mock import patch
from apples.models import Apple
from apples.signals import apple_eaten


class TestReceivers(TestCase):

    def test_receiver_connection(self):
        """Tests that my_receiver is connected to the apple_eaten signal."""
        apple = Apple()
        with patch('myapp.receivers.my_receiver') as mock_receiver:
            apple_eaten.send(Apple, instance=apple)
         
        mock_receiver.assert_called_once_with(sender=None, instance=apple)
     

What's the problem here?

Testing gotchas

from myapp.receivers import my_receiver

Be very careful with imports when testing connection of receivers.

 

Any imports or mocking of anything in the receivers module will automatically connect your receiver.

with patch('myapp.receivers.my_receiver') as mock_receiver:
     

Testing receivers without import

from django.test import TestCase, skip
from apples.models import Apple
from apples.signals import apple_eaten
from django.db.models import signals


class ReceiverConnectionTestCase(TestCase):
    """TestCase that allows asserting that a given receiver is connected to a signal.
    """
    def assert_receiver_is_connected(self, receiver_string, signal, sender):
        receivers = signal._live_receivers(sender)
        receiver_strings = ["{}.{}".format(r.__module__, r.__name__) for r in receivers]
        if receiver_string not in receiver_strings:
            raise AssertionError('{} is not connected to signal.'.format(receiver_string))


class TestConnection(ReceiverConnectionTestCase):
    def test_receiver_is_connected(self):
        self.assert_receiver_is_connected('myapp.receivers.my_receiver', apple_eaten, Apple)

Gist: http://bit.ly/2mmaWeS

What happens when we add behaviour testing?

Testing behaviour

Testing connection

OR

If any of your tests import or mock anything in your receivers module, this will undermine your connection test.

Test execution order

So...

  1. django.test.TestCase
  2. All other Django-based tests
  3. unittest.TestCase

My best answer

django.test.TestCase
  • Receiver connection tests
  • Other tests that hit the database
  • Nothing that imports or mocks receivers
unittest.TestCase
  • Mocky unit tests for receiver behaviour
  • Other tests that don't hit the database

Nowhere in any test should anything from receivers file be imported, except within a test method context, or in a mock patch.

Main take aways

  1. Only use signals to avoid introducing circular dependencies.
     

  2. Signals in signals.py,

    receivers in receivers.py.
     

  3. Beware when importing or mocking receivers in your tests.

This talk's slides:

slides.com/davidseddon/signals

 

We're hiring!

www.growthstreet.co.uk/careers

 

Encapsulated Django talk:

http://bit.ly/2mrH9hZ

 

 

David Seddon

david@seddonym.me

seddonym.me

 

Signals

By David Seddon

Signals

When (and when not) to use them

  • 580
Loading comments...

More from David Seddon